So far, we have tested use cases, repositories and controllers.
But we have not tested end-to-end.
To ensure that works, I always add a few @SpringBootTest
tests as well.
It makes little sense to try to replicate every possible scenario, but the most important paths of the application are best covered with such tests.
This should give you good confidence that the application will work fine.
As the application grows, there might be bugs that are only reproducable in an end-to-end test.
I always first try to isolate the bug and see if I can write a use case test, repository test or controller test.
If that is not possible, I will write a @SpringBootTest
to ensure there is no regression in the future.
Just as with @DataJpaTest
, I write a meta-annation for all the integration tests:
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(TestcontainersConfiguration.class)
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(CleanDatabaseExtension.class)
public @interface PetclinicSpringBootTest {
}
It again imports TestcontainersConfiguration
so we have our real PostgreSQL database in Docker running.
We also need to add AutoConfigureMockMvc
so we can use MockMvc
to drive the interaction with the application.
An important part of integration tests is the cleanup.
Some people add @Transactional
with @SpringBootTest
, but that is a bad idea.
Sure, your database gets rolled back at the end of the test so the database is fresh for the next test.
But by making the test transactional, you influence how the transactions of the application behave.
You can have a transaction already because the test started it, but if you run your application, it fails as there is no transaction in reality.
The whole point of these kind of integration tests is to be as realistic as possible.
For that reason, it is better to add a JUnit extension to clean up the database (or any other resource that might need cleaning up).
Our meta-annation has @ExtendWith(CleanDatabaseExtension.class)
for this purpose.
The code of the extension itself looks like this:
import com.wimdeblauwe.petclinic.infrastructure.repository.DatabaseCleaner;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.springframework.test.context.junit.jupiter.SpringExtension;
public class CleanDatabaseExtension implements AfterEachCallback {
@Override
public void afterEach(ExtensionContext context) throws Exception {
var applicationContext = SpringExtension.getApplicationContext(context);
DatabaseCleaner cleaner = applicationContext.getBean(DatabaseCleaner.class);
cleaner.clean();
}
}
It basically delegates to a DatabaseCleaner
class after each test has run.
This is the code for the cleaner itself:
import com.wimdeblauwe.petclinic.owner.repository.OwnerRepository;
import com.wimdeblauwe.petclinic.veterinarian.repository.VeterinarianRepository;
import com.wimdeblauwe.petclinic.visit.repository.VisitRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Transactional
public class DatabaseCleaner {
private final OwnerRepository ownerRepository;
private final VeterinarianRepository veterinarianRepository;
private final VisitRepository visitRepository;
public DatabaseCleaner(OwnerRepository ownerRepository,
VeterinarianRepository veterinarianRepository,
VisitRepository visitRepository) {
this.ownerRepository = ownerRepository;
this.veterinarianRepository = veterinarianRepository;
this.visitRepository = visitRepository;
}
public void clean() {
visitRepository.deleteAll();
veterinarianRepository.deleteAll();
ownerRepository.deleteAll();
}
}
It injects all the repositories and calls deleteAll()
in the proper order.
With all this setup in place, we can get to our actual integration test:
import com.jayway.jsonpath.JsonPath;
import com.wimdeblauwe.petclinic.infrastructure.test.PetclinicSpringBootTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@PetclinicSpringBootTest
class PetclinicApplicationTests {
@Autowired
private MockMvc mockMvc;
@Test
void happyPathToPlannedVisits() throws Exception {
// 1. Create an owner with a pet
MvcResult ownerResult = mockMvc.perform(post("/api/owners")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"owner": {
"firstName": "John",
"lastName": "Doe",
"street": "123 Main Street",
"city": "Springfield",
"telephone": "123-456-7890"
},
"pet": {
"name": "Rufus",
"birthDate": "2020-01-01",
"type": "DOG",
"weightInGrams": 10000
}
}
"""))
.andExpect(status().isCreated())
.andReturn();
// Extract owner and pet id from the response
String ownerId = JsonPath.read(ownerResult.getResponse().getContentAsString(), "$.id");
String petId = JsonPath.read(ownerResult.getResponse().getContentAsString(), "$.pets[0].id");
// 2. Create a veterinarian
MvcResult vetResult = mockMvc.perform(post("/api/veterinarians")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"firstName": "Jane",
"lastName": "Smith",
"specialities": [
{
"name": "Surgery",
"since": "2020-01-01"
}
]
}
"""))
.andExpect(status().isCreated())
.andReturn();
// Extract veterinarian ID from the response
String veterinarianId = JsonPath.read(vetResult.getResponse().getContentAsString(), "$.id");
// 3. Plan a visit
Instant appointmentTime = Instant.now().plus(Duration.of(1, ChronoUnit.HOURS));
mockMvc.perform(post("/api/visits")
.contentType(MediaType.APPLICATION_JSON)
.content(String.format("""
{
"veterinarianId": "%s",
"ownerId": "%s",
"petId": "%s",
"appointmentTime": "%s"
}
""", veterinarianId, ownerId, petId, appointmentTime)))
.andExpect(status().isCreated());
}
}
Using the API of our application, we simulate a complete user interaction in this integration test.
The integration test above works well for a single test, but as you add more integration tests as the application grows, you’ll notice significant duplication.
Let’s see how API clients can help reduce this maintenance burden.
To avoid having to duplicate those MockMvc lines for common things you need in a lot of the integration tests, I sometimes write an API client that hides that calling of MockMvc.
Imagine having 20 integration tests that all need to create an owner with a pet.
Without an API client, you’d have the same 15-20 lines of MockMvc setup repeated across all tests.
When you need to change the owner creation API, you’d have to update all 20 tests.
The API client pattern solves this maintenance nightmare.
I usually create a separate API client per package (feature) of the application.
For example, for the owner
package we can have this:
import com.jayway.jsonpath.JsonPath;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
public class OwnerApiClient {
private final MockMvc mockMvc;
public OwnerApiClient(MockMvc mockMvc) {
this.mockMvc = mockMvc;
}
public OwnerIdWithPetId createOwnerWithPet() throws Exception {
MvcResult ownerResult = mockMvc.perform(post("/api/owners")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"owner": {
"firstName": "John",
"lastName": "Doe",
"street": "123 Main Street",
"city": "Springfield",
"telephone": "123-456-7890"
},
"pet": {
"name": "Rufus",
"birthDate": "2020-01-01",
"type": "DOG",
"weightInGrams": 10000
}
}
"""))
.andExpect(status().isCreated())
.andReturn();
// Extract owner and pet id from the response
String ownerId = JsonPath.read(ownerResult.getResponse().getContentAsString(), "$.id");
String petId = JsonPath.read(ownerResult.getResponse().getContentAsString(), "$.pets[0].id");
return new OwnerIdWithPetId(ownerId, petId);
}
public record OwnerIdWithPetId(String ownerId, String petId) {
}
}
I also created similar classes for VeterinarianApiClient
and VisitApiClient
.
Next, I group everything in an application API client:
public record PetclinicApiClient(OwnerApiClient ownerApiClient,
VeterinarianApiClient veterinarianApiClient,
VisitApiClient visitApiClient) {
}
Finally, I ensure this application API client is available in the test for autowiring via a @TestConfiguration
:
import com.wimdeblauwe.petclinic.integration.api.OwnerApiClient;
import com.wimdeblauwe.petclinic.integration.api.PetclinicApiClient;
import com.wimdeblauwe.petclinic.integration.api.VeterinarianApiClient;
import com.wimdeblauwe.petclinic.integration.api.VisitApiClient;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.test.web.servlet.MockMvc;
@TestConfiguration
public class PetclinicSpringBootTestWithApiClientTestConfiguration {
@Bean
public PetclinicApiClient petclinicApiClient(MockMvc mockMvc) {
return new PetclinicApiClient(
new OwnerApiClient(mockMvc),
new VeterinarianApiClient(mockMvc),
new VisitApiClient(mockMvc)
);
}
}
Don’t forget to import it into the custom annotation:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import({TestcontainersConfiguration.class, PetclinicSpringBootTestWithApiClientTestConfiguration.class})
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(CleanDatabaseExtension.class)
public @interface PetclinicSpringBootTestWithApiClient {
}
The integration test is now simplied to this:
import com.wimdeblauwe.petclinic.infrastructure.test.PetclinicSpringBootTestWithApiClient;
import com.wimdeblauwe.petclinic.integration.api.OwnerApiClient;
import com.wimdeblauwe.petclinic.integration.api.PetclinicApiClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@PetclinicSpringBootTestWithApiClient
public class VisitIntegrationTest {
@Autowired
private PetclinicApiClient petclinicApiClient;
@Test
void happyPathToPlannedVisit() throws Exception {
OwnerApiClient.OwnerIdWithPetId ownerWithPet = petclinicApiClient.ownerApiClient().createOwnerWithPet();
String veterinarianId = petclinicApiClient.veterinarianApiClient().createVeterinarian();
petclinicApiClient.visitApiClient().planVisit(ownerWithPet.ownerId(), ownerWithPet.petId(), veterinarianId);
}
}
It looks a bit weird for this simple case, but you can imagine the usefulness if you have lots of integration tests.
One challenge with API clients is balancing convenience with flexibility.
In this example, createOwnerWithPet()
uses hardcoded values, which works for most tests but might not suit specific scenarios.
You could add parameters like createOwnerWithPet(String firstName, String lastName)
, but this can lead to method proliferation.
My approach is to start simple with sensible defaults, then add customization options only when multiple tests need them.
For one-off scenarios, it’s often better to use the raw MockMvc calls rather than overloading the API client with rarely-used parameters.
This concludes the sections on testing if our code does what we want it to do.
We will now investigate how we can be sure our code is written in a way that the team has agreed upon.
|
Using real HTTP integration test
Using MockMvc is good most of the time.
However, real HTTP tests catch issues that MockMvc might miss, such as actual HTTP serialization problems, Tomcat-specific configuration issues, servlet filter behavior, and multipart file handling edge cases.
If you want to be even more sure that everything works fine in the real application, you can have @SpringBootTest start the real Tomcat (or whatever you have configured in your dependencies) and use RestAssured to interact with the API.
-
Add the io.rest-assured:rest-assured dependency to your project.
-
Create a new annotation @PetclinicSpringBootRealTomcatTest to use the RANDOM_PORT web environment insteaed of the default MOCK ,
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(TestcontainersConfiguration.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith({CleanDatabaseExtension.class,RestAssuredSetupExtension.class})
public @interface PetclinicSpringBootRealTomcatTest {
}
-
Create a Junit extension that will set up RestAssured to connect to the correct random port:
import com.fasterxml.jackson.databind.ObjectMapper;
import io.restassured.RestAssured;
import io.restassured.config.LogConfig;
import io.restassured.config.ObjectMapperConfig;
import io.restassured.config.RestAssuredConfig;
import io.restassured.filter.log.LogDetail;
import io.restassured.internal.mapping.Jackson2Mapper;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.springframework.core.env.Environment;
import org.springframework.test.context.junit.jupiter.SpringExtension;
public class RestAssuredSetupExtension implements BeforeEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
var applicationContext = SpringExtension.getApplicationContext(context);
Environment env = applicationContext.getEnvironment();
int port = Integer.parseInt(env.getProperty("local.server.port"));
ObjectMapper objectMapper = (ObjectMapper) applicationContext.getBean("jacksonObjectMapper");
RestAssured.port = port;
RestAssured.config = RestAssuredConfig.config()
.logConfig(LogConfig.logConfig().enableLoggingOfRequestAndResponseIfValidationFails(LogDetail.ALL))
.objectMapperConfig(new ObjectMapperConfig(new Jackson2Mapper((type, s) -> objectMapper)));
}
}
-
Use the RestAssured API to drive the HTTP interactions.
@Test
void happyPathToPlannedVisits() {
// 1. Create an owner with a pet
RestAssured.given()
.contentType(ContentType.JSON)
.body("""
{
"owner": {
"firstName": "John",
"lastName": "Doe",
"street": "123 Main Street",
"city": "Springfield",
"telephone": "123-456-7890"
},
"pet": {
"name": "Rufus",
"birthDate": "2020-01-01",
"type": "DOG",
"weightInGrams": 10000
}
}
""")
.when()
.post("/api/owners")
.then()
.statusCode(HttpStatus.CREATED.value());
...
}
|