The previous lesson left RentalService well tested and an uncomfortable list of things that mocks cannot reach. That @PreAuthorize really is in place on finish and that Spring applies it. That StationController translates a ResourceNotFoundException into a 404 with a ProblemDetail body. That findByUserIdAndEndedAtIsNull generates the SQL we think it does. That anyRequest().denyAll() closes off what it should. All of it has something in common: what can fail is not the logic, it is the wiring, the annotations and the framework's behaviour, and a mock knows nothing about any of that.
This lesson starts the Spring context, but with judgement. We will look at what @SpringBootTest starts and its four web environments, the context cache that decides whether your suite takes forty seconds or seven minutes, the slice tests that load only one layer, MockMvc for testing controllers, @DataJpaTest for queries, TestRestTemplate and WebTestClient for the full process, and the spring-security-test annotations that will turn every rule from module 5 into an automatic assertion. By the end, the promise that opened the module will have been kept: a citizen trying to finish somebody else's rental will produce a 403 verified by a test, not by good intentions.
Contents
- What an integration test adds
@SpringBootTestandwebEnvironment- The application context cache
- Slice tests
@WebMvcTestwithMockMvc- Testing errors and validation
MockMvcTester, the fluent API@DataJpaTestTestRestTemplateandWebTestClient- Testing the security of module 5
- Test-specific configuration
- Data with
@SqlandApplicationContextRunner - Common Mistakes and Tips
- Exercises
- What an integration test adds
An integration test in Spring Boot starts an application context and tests several pieces working together. What it adds over unit tests is exactly what unit tests cannot see:
| Real possible failure in CicloUrbana | Does a unit test see it? |
|---|---|
@Valid is missing on the @RequestBody, and the validation of 03-04 is not applied |
No |
GlobalExceptionHandler does not catch StationFullException |
No |
| A badly named derived query that Spring Data cannot translate | No: it fails when creating the context |
@Transactional on a private method, which the proxy does not intercept |
No |
An authorizeHttpRequests rule in the wrong order |
No |
An Instant that Jackson serialises as a number instead of ISO-8601 |
No |
| The calculation of the excess-duration surcharge | Yes, and that is where it should be tested |
The last row is the important contrast: what a unit test can test, gets tested in a unit test. Integration is expensive —seconds against milliseconds— and is reserved for what only it can catch.
@SpringBootTest and webEnvironment
@SpringBootTest and webEnvironment@SpringBootTest searches upwards through the packages for the class annotated with @SpringBootApplication —which is why the test must live in com.ciclourbana or below— and starts the full context: every bean, the autoconfiguration of 02-06, the data source, Flyway and the security filter chain.
webEnvironment |
What it starts | How the API is called | When to use it |
|---|---|---|---|
MOCK (default) |
A simulated web context, no server | MockMvc |
Almost always: fast and sufficient |
RANDOM_PORT |
A real Tomcat on a free port | TestRestTemplate, WebTestClient |
Testing serialisation, filters and real HTTP codes |
DEFINED_PORT |
Tomcat on the port from application.yml |
The same | Almost never: it clashes in continuous integration |
NONE |
Context with no web layer | By calling the beans | Testing services and repositories with the full context |
RANDOM_PORT avoids the classic "Address already in use" when two builds run at once on the same agent. The assigned port is injected with @LocalServerPort int port, although TestRestTemplate already resolves it on its own with relative paths.
The cost to keep in mind: starting CicloUrbana's full context —JPA, Hibernate, HikariCP, Flyway, Spring Security, springdoc— takes between two and six seconds. A hundred tests like that would be ten minutes, and a ten-minute suite stops being run. The solution has two parts: the cache in the next section and the slices in section 4.
- The application context cache
This is the key to the performance of the whole suite, and anyone who does not understand it ends up with a slow suite without knowing why.
Spring Test does not create one context per test class: it keeps a cache and reuses the context across classes whenever the configuration is identical. The cache key is made up of the configuration classes, the active profiles, the properties, the initialisers and other elements. If two classes ask for the same configuration, the context is started once and both run fast.
flowchart LR
A["StationServiceIT<br/>@SpringBootTest"] --> K["Key: config + profiles<br/>+ properties + mocks"]
B["RentalServiceIT<br/>@SpringBootTest"] --> K
C["SecurityIT<br/>@SpringBootTest + @MockitoBean"] --> K2["DIFFERENT key"]
K --> CTX1["Context 1 · started once"]
K2 --> CTX2["Context 2 · another 4 s start-up"]
What invalidates the cache and forces a new context:
| Element | Effect |
|---|---|
@MockitoBean / @MockitoSpyBean |
A new context for every distinct combination of replaced beans |
@TestPropertySource(properties = ...) |
A new context for every set of properties |
@ActiveProfiles("other") |
A new context for every combination of profiles |
@DirtiesContext |
Destroys the context after the class or the method |
A different slice (@WebMvcTest versus @DataJpaTest) |
Different contexts by definition |
Concrete tips for not fragmenting it:
- Standardise. Have every integration test use
@ActiveProfiles("test")and the same properties. One class with a different profile "just for this test" costs the suite four seconds. - Centralise in a base class. An
IntegrationTestBaseannotated just once, inherited by all of them, guarantees a single cache key. It is the pattern 06-05 will extend with the PostgreSQL container. - Group the
@MockitoBeans. If three classes mock the same bean, have them declare it identically: they share a context. If each mocks a different one, that is three contexts. @DirtiesContextis the last resort. Use it when a test modifies the context irreversibly. Every use adds a full start-up; if it turns up in several classes, the real problem is almost always a test that does not clean up its own mess.
To see what is going on, switch on the cache manager's logging:
And you will see lines such as Spring test ApplicationContext cache statistics: [size = 4, hitCount = 37, missCount = 4]. A high missCount is the symptom of a fragmented suite.
- Slice tests
A slice loads only the part of the context one layer needs: the beans of that layer and its autoconfiguration, and nothing else. They are far faster than @SpringBootTest and their failures are far more precise.
| Annotation | What it autoconfigures | What it does not load | For testing |
|---|---|---|---|
@WebMvcTest |
DispatcherServlet, controllers, @ControllerAdvice, Jackson, MockMvc, security filters |
@Service, @Repository, JPA, data source |
StationController, RentalController |
@DataJpaTest |
JPA, Hibernate, repositories, TestEntityManager, embedded database, transaction with rollback |
Controllers, services, web security | RentalRepository, entities, queries |
@JsonTest |
Jackson and its ObjectMapper, JacksonTester |
Everything else | Serialisation of the DTOs of 03-05 |
@RestClientTest |
RestTemplateBuilder/RestClient and MockRestServiceServer |
Everything else | The external service client of 07-06 |
@JdbcTest / @DataJdbcTest |
JdbcTemplate / Spring Data JDBC, without JPA |
JPA, controllers | Projects without JPA |
The practical consequence of the "what it does not load" column: in a @WebMvcTest, the StationService the controller needs does not exist, and the context fails to start unless you provide it. That is where @MockitoBean comes in.
@WebMvcTest with MockMvc
@WebMvcTest with MockMvcpackage com.ciclourbana.stations;
@WebMvcTest(StationController.class) // only THIS controller; without it, all of them
@DisplayName("StationController · public stations API")
class StationControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
/** Replaces the real bean in the context with a Mockito mock.
* From Spring Boot 3.4, @MockBean is DEPRECATED in favour of this one. */
@MockitoBean private StationService stationService;
@Test
void returnsTheStationAndItsFieldsWhenItExists() throws Exception {
when(stationService.getById(1L))
.thenReturn(new StationResponse(1L, "Main Square", "Main Square 1", 24, 9));
mockMvc.perform(get("/api/v1/stations/{id}", 1L))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.name").value("Main Square"))
.andExpect(jsonPath("$.capacity").value(24))
.andExpect(jsonPath("$.passwordHash").doesNotExist()); // no leaking
}
@Test
void createsTheStationAndReturnsTheLocationHeader() throws Exception {
CreateStationRequest request =
new CreateStationRequest("Old Market", "Market Street 8", 18);
when(stationService.create(any())).thenReturn(
new StationResponse(5L, "Old Market", "Market Street 8", 18, 18));
mockMvc.perform(post("/api/v1/stations")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/v1/stations/5"));
}
}Anatomy of the call, piece by piece:
@WebMvcTest(StationController.class)limits the slice to one controller. Without the argument it loads them all, which starts more slowly and lets a failure in another controller break this test.@MockitoBean(packageorg.springframework.test.context.bean.override.mockito) replaces the bean in the context.@MockBeanhas been deprecated since Spring Boot 3.4 and will disappear; the new annotation belongs to the generic bean-overriding mechanism of Spring Framework 6.2, of which@MockitoSpyBeanand@TestBeanare also part. Remember from section 3 that every distinct combination of@MockitoBeancreates a new context.get(...),post(...),put(...),delete(...)are statics fromMockMvcRequestBuilders. It accepts path variables as arguments ("/{id}", 1L), and supports.param(...),.header(...),.contentType(...),.accept(...).status(),jsonPath(),header(),content()are statics fromMockMvcResultMatchers.jsonPathuses Hamcrest, which is the one exception to the "AssertJ only" rule of 06-02..andDo(print())dumps request and response to the console: it is the first thing you add when something does not add up.
The assertion jsonPath("$.passwordHash").doesNotExist() matters more than it looks: it is the contract test that 03-05 promised, the one that catches somebody adding an internal field to the public response.
What a web slice tests and what it does not. It tests route mapping, body deserialisation, validation, response serialisation, status codes, headers and the exception handlers. It does not test the service logic —that is mocked— nor persistence. That is exactly the right division.
- Testing errors and validation
The ProblemDetail responses of 03-06 and the validation responses of 03-04 are public contract, so they deserve tests of their own:
@Test
void returns404InProblemDetailFormatWhenTheStationDoesNotExist() throws Exception {
when(stationService.getById(99L))
.thenThrow(new ResourceNotFoundException("Station", 99L));
mockMvc.perform(get("/api/v1/stations/99"))
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.type").value("https://api.ciclourbana.example/errors/resource-not-found"))
.andExpect(jsonPath("$.title").value("Resource not found"))
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.detail").value(containsString("99")))
.andExpect(jsonPath("$.instance").value("/api/v1/stations/99"));
}
@Test
void returns400WithTheListOfInvalidFieldsWhenCreatingAMalformedStation() throws Exception {
String body = """
{"name": "", "address": "Fake Street 1", "capacity": -5}
""";
mockMvc.perform(post("/api/v1/stations")
.contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors", hasSize(2)))
.andExpect(jsonPath("$.errors[*].field",
containsInAnyOrder("name", "capacity")));
verifyNoInteractions(stationService); // the request never reached the service
}That last verifyNoInteractions is the assertion that makes the second test valuable: it checks that validation cuts in at the edge, which is exactly what 03-04 was aiming for. And the error response is tested here, and not in a unit test, because it is produced by the cooperation of @Valid, MethodArgumentNotValidException and the @RestControllerAdvice: none of the three pieces generates it on its own.
To compare the whole JSON instead of field by field, content().json(expected) uses JSONassert and ignores key order; with content().json(expected, true) the comparison is strict and fails if there is any extra field.
MockMvcTester, the fluent API
MockMvcTester, the fluent APISpring Framework 6.2 introduces MockMvcTester, a layer over MockMvc with AssertJ assertions and no throws Exception:
@Autowired private MockMvcTester mockMvc; // autoconfigured in @WebMvcTest
@Test
void returnsTheStationWithTheFluentApi() {
assertThat(mockMvc.get().uri("/api/v1/stations/{id}", 1L))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson().extractingPath("$.name").isEqualTo("Main Square");
}Classic MockMvc |
MockMvcTester |
|
|---|---|---|
| Style | Chained andExpect(...) |
AssertJ's assertThat(...) |
| Exceptions | throws Exception on every method |
No |
| Matchers | Hamcrest | AssertJ |
| Maturity | Ubiquitous: all the documentation and examples | Recent |
It is more consistent with the rest of the course, but this module uses classic MockMvc for a practical reason: it is what you will find in any project and in any answer you look up. It is worth knowing both and not mixing them within the same class.
@DataJpaTest
@DataJpaTest@DataJpaTest
@DisplayName("RentalRepository · queries of the Ribalta network")
class RentalRepositoryTest {
@Autowired private RentalRepository rentalRepository;
@Autowired private TestEntityManager em;
@Test
void findByUserIdAndEndedAtIsNullReturnsOnlyRentalsInProgress() {
User marta = em.persist(TestUsers.newMarta());
em.persist(TestRentals.inProgressFor(marta));
em.persist(TestRentals.finishedFor(marta));
em.flush(); // forces the SQL: without this, the query may not see them
em.clear(); // empties the first-level cache: real reads
List<Rental> inProgress = rentalRepository.findByUserIdAndEndedAtIsNull(marta.getId());
assertThat(inProgress).hasSize(1)
.allSatisfy(r -> assertThat(r.getEndedAt()).isNull());
}
}Three characteristics you have to understand:
Embedded database by default. If H2 is on the classpath, @DataJpaTest replaces the real data source with an in-memory one. It is fast and it is the whole reason lesson 06-05 exists: H2 is not PostgreSQL, and there are queries that pass here and fail in Ribalta. To use the configured data source you add @AutoConfigureTestDatabase(replace = Replace.NONE).
Transaction with automatic rollback. Every test runs inside a transaction that is rolled back at the end, so tests do not contaminate each other and there is nothing to clean up. Its two implications:
- Everything happens in the same Hibernate session, so a persisted entity is still in the first-level cache and a query could return it without touching the database. Hence the
em.flush()and theem.clear(): without them the test can pass even when the generated SQL is wrong. It is the commonest mistake with@DataJpaTest. - The rollback hides deferred constraint failures and does not test the behaviour of a real
commit. If that matters, use@Transactional(propagation = NOT_SUPPORTED)on the test and clean up by hand.
TestEntityManager is a wrapper over the EntityManager with methods designed for tests: persist, persistFlushFind, flush, clear, refresh. It is used to arrange data; what gets verified is always the repository, which is what is under test.
TestRestTemplate and WebTestClient
TestRestTemplate and WebTestClientWith RANDOM_PORT there is a real Tomcat and the API is called over genuine HTTP, inside the same process:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class StationApiIT {
@Autowired private TestRestTemplate client; // relative paths: it resolves the port
@Test
void listsTheFourRibaltaStationsPaginated() {
ResponseEntity<String> response =
client.getForEntity("/api/v1/stations?size=10", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("Main Square", "University");
}
}WebTestClient is the modern, fluent alternative, valid for WebFlux as well; it requires spring-webflux on the test classpath and is autoconfigured with @AutoConfigureWebTestClient.
MockMvc |
TestRestTemplate / WebTestClient |
|
|---|---|---|
| Server | No: it simulates the DispatcherServlet |
A real Tomcat |
| Serialisation | Real (Jackson) | Real |
| Network layer and Servlet filters | Does not go through them | Yes, all of them |
| Speed | Milliseconds | Tens of milliseconds + start-up |
| Access to the internal beans | Yes, the context is at hand | Yes |
| Recommended use | 90 % of web layer tests | A few complete critical paths |
TestRestTemplate has one useful difference from RestTemplate: it does not throw on 4xx and 5xx codes, which lets you assert on a 404 without wrapping the call in a try.
- Testing the security of module 5
This is where the promise that closed module 5 is kept. First, the dependency:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>| Annotation / utility | What it does |
|---|---|
@WithMockUser(roles = "OPERATOR") |
Simulated authentication, without touching the database |
@WithAnonymousUser |
An explicitly anonymous request |
@WithUserDetails("[email protected]") |
Loads the real user with AppUserDetailsService: needs data |
@WithSecurityContext |
The basis for your own annotations |
SecurityMockMvcRequestPostProcessors.user(...) |
The same, but per request: .with(user(...)) |
...jwt() / ...csrf() |
Simulating an OAuth2 resource token or adding the CSRF token |
One detail to pin down: @WithMockUser(roles = "ADMIN") adds the authority ROLE_ADMIN, because it applies the prefix. With authorities = "ROLE_ADMIN" you state the literal authority. Confusing them —writing roles = "ROLE_ADMIN" and ending up with ROLE_ROLE_ADMIN— is a classic that produces an inexplicable 403.
In a web slice you have to import the security configuration, because @WebMvcTest loads the filters but not your SecurityConfig:
@WebMvcTest(RentalController.class)
@Import({SecurityConfig.class, JwtService.class})
class RentalControllerSecurityTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private RentalService rentalService;
@MockitoBean private AppUserDetailsService appUserDetailsService;
@Test
@WithAnonymousUser
void requiresAuthenticationToListTheRentals() throws Exception {
mockMvc.perform(get("/api/v1/rentals")).andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "CITIZEN")
void allowsACitizenToListTheirOwnRentals() throws Exception {
mockMvc.perform(get("/api/v1/rentals")).andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "CITIZEN")
void forbidsACitizenFromReachingTheOperatorPanel() throws Exception {
mockMvc.perform(get("/api/v1/internal/bikes")).andExpect(status().isForbidden());
}
@Test
void returns401WithNoTokenInsteadOfRedirectingToTheLoginForm() throws Exception {
mockMvc.perform(post("/api/v1/rentals/9/finish"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.status").value(401)); // ProblemDetail, not HTML
}
}An annotation of your own saves repeating the same configuration in thirty tests:
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "[email protected]", roles = "CITIZEN")
public @interface AsCitizen { }
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "[email protected]", roles = "OPERATOR")
public @interface AsOperator { }And now the test that closes the circle of module 5: the @PreAuthorize rule with RentalSecurity.isOwner. In 06-03 we tested the bean in isolation; what is missing is checking that Spring applies the annotation, and that requires the context with method security switched on:
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class RentalSecurityIT {
@Autowired private MockMvc mockMvc;
@Test
@WithUserDetails("[email protected]") // CITIZEN, id 7, genuinely loaded
void aCitizenCannotFinishSomeoneElsesRental() throws Exception {
// Rental 9 belongs to citizen 12, not to Marta
mockMvc.perform(post("/api/v1/rentals/9/finish")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"destinationStationId\": 2}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.title").value("Access denied"));
}
@Test
@WithUserDetails("[email protected]")
void aCitizenCanFinishTheirOwnRental() throws Exception {
mockMvc.perform(post("/api/v1/rentals/4/finish") // number 4 is Marta's
.contentType(MediaType.APPLICATION_JSON)
.content("{\"destinationStationId\": 2}"))
.andExpect(status().isOk());
}
}These two tests are the answer to the question that opened the module. The hole that 05-05 closed is now watched over by assertions that run on every build; if somebody deletes the annotation, reorders the SpEL expression or renames the bean, the suite goes red. And note the pair: a denial test and a permission test. Without the second one, a rule that always denied —the commonest mistake when writing SpEL, which the compiler does not check— would go unnoticed.
About csrf(): CicloUrbana disabled it with justification in 05-02, being a sessionless API, so it is not needed here. In an application with sessions, a POST without .with(csrf()) returns 403 and baffles you; that 403 is the sign that the token is missing, not a role problem.
- Test-specific configuration
# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:h2:mem:ribalta;DB_CLOSE_DELAY=-1
jpa:
hibernate:
ddl-auto: create-drop # fine in tests; in production, validate (04-08)
show-sql: true
flyway:
enabled: false # Hibernate creates the schema in this slice
ciclourbana:
network:
minimum-capacity: 8
battery-threshold: 20
jwt:
secret: test-signing-key-of-at-least-43-base64-characters-for-hs256
expiration: 15m
logging:
level:
org.springframework.security: DEBUGRemember from 06-01: the file is called application-test.yml, not application.yml. An application.yml in src/test/resources does not merge with the production one, it replaces it.
| Mechanism | Scope | Effect on the cache |
|---|---|---|
@ActiveProfiles("test") |
Class | A new key per combination of profiles |
@TestPropertySource(properties = "...") |
Class | A new key per set of properties |
@DynamicPropertySource |
Class, values computed at run time | The same; it is the gateway to Testcontainers (06-05) |
@TestConfiguration + @Import |
Class | A new key |
@DynamicPropertySource deserves a look, because it is what will make the next lesson possible: it lets you compute properties after something external has started but before the context is created.
@DynamicPropertySource
static void dynamicProperties(DynamicPropertyRegistry registry) {
registry.add("ciclourbana.notifications.url", fakeServer::getUrl);
}And @TestConfiguration contributes beans only to the tests that import it —unlike @Configuration, which would be picked up by scanning and affect everything:
@TestConfiguration
public class FixedClockConfig {
@Bean
@Primary // wins over the Clock from CommonConfig
public Clock frozenClock() {
return Clock.fixed(Instant.parse("2026-03-14T09:00:00Z"), ZoneOffset.UTC);
}
}Importing it with @Import(FixedClockConfig.class) makes any time-dependent integration test deterministic, with the same idea as 06-02 but at context scale.
- Data with
@Sql and ApplicationContextRunner
@Sql and ApplicationContextRunner@Sql runs scripts before or after a test:
@Test
@Sql(scripts = "/data/ribalta-stations.sql") // before
@Sql(scripts = "/data/clean-up.sql", executionPhase = AFTER_TEST_METHOD)
void listsTheStationsLoadedByTheScript() throws Exception { /* ... */ }Placed on the class it applies to all its methods, and several scripts run in order. Against arranging the data with TestEntityManager or the repositories, @Sql wins when the data set is large or when you need a state the entities do not allow you to build; it loses in that the script drifts out of sync with the schema as soon as a new migration lands. For CicloUrbana the rule is: small, expressive data with TestEntityManager and the factories of 06-02; large, stable data sets with @Sql.
And for the other end of the spectrum, ApplicationContextRunner —which already appeared in 02-06— tests the configuration itself without starting any full context, in milliseconds:
@Test
void startupFailsWhenTheJwtSecretIsMissing() {
new ApplicationContextRunner()
.withUserConfiguration(JwtConfig.class)
.run(context -> assertThat(context)
.hasFailed()
.getFailure()
.hasMessageContaining("ciclourbana.jwt.secret"));
}
@Test
void registersTheSeniorFareAutomaticallyWhenItIsOnTheClasspath() {
new ApplicationContextRunner()
.withUserConfiguration(StandardFare.class, StudentFare.class,
SeniorFare.class, FareSelector.class)
.run(context -> assertThat(context.getBean(FareSelector.class).available())
.containsKeys("standard", "student", "senior"));
}
@Test
void doesNotRegisterTheStarterWhenThePropertyIsDisabled() {
new ApplicationContextRunner()
.withPropertyValues("ciclourbana.network.enabled=false")
.withConfiguration(AutoConfigurations.of(NetworkAutoConfiguration.class))
.run(context -> assertThat(context).doesNotHaveBean(NetworkProperties.class));
}It is the ideal tool for verifying @ConditionalOnProperty, @ConditionalOnMissingBean and the @ConfigurationProperties validation of module 2: it checks context behaviour without paying for the start-up or fragmenting the cache.
Common Mistakes and Tips
Using @SpringBootTest for everything. It is the direct route to the ice-cream cone of 06-01. Before writing it, ask yourself which layer you are testing: there is almost always a slice that suffices and costs a tenth as much.
Still using @MockBean. It has been deprecated since Spring Boot 3.4. Replace it with @MockitoBean (and @SpyBean with @MockitoSpyBean); the change is mechanical and avoids a forced migration later on.
Fragmenting the context cache without realising. A @TestPropertySource added "just for this test" adds a full start-up. Before adding configuration to a class, see whether it can live in the shared test profile.
Forgetting em.flush() and em.clear() in @DataJpaTest. The query is answered from the first-level cache and the test passes even when the SQL is wrong. It is the most dangerous false positive in this lesson.
Putting application.yml in src/test/resources. It replaces the production one instead of complementing it. Use application-test.yml with @ActiveProfiles("test").
@WebMvcTest without @Importing the security configuration. The tests pass because there are no rules to apply, giving a false sense of security coverage.
Writing only denial tests. Checking the 403s without checking the 200s lets through a rule that always denies, which is the commonest SpEL failure. Every rule needs its pair.
Tip: one base class for the integration tests. Concentrate @SpringBootTest, @ActiveProfiles("test") and the shared configuration in a single place: one cache key, one start-up, and a single point where 06-05 will plug in the PostgreSQL container.
Tip: .andDo(print()) is your first tool. Faced with an incomprehensible MockMvc failure, dumping request and response solves most cases in thirty seconds.
Tip: name integration tests *IT. Failsafe separates them from the fast ones (06-01), so ./mvnw test still takes seconds and ./mvnw verify checks everything.
Exercises
Exercise 1
Write a complete StationControllerTest with @WebMvcTest covering four cases of the public contract: a paginated listing returning a PageResponse with its content, page and totalElements fields; the 404 with ProblemDetail for a non-existent station; the 400 when creating with capacity -5, verifying that the service was never invoked; and the 409 with code DUPLICATE_STATION when the service throws DuplicateStationException. Explain why all four are slices and none needs @SpringBootTest.
Exercise 2
Create the @AsCitizen annotation from section 10 and write CicloUrbana's access matrix as tests: for the routes GET /api/v1/stations, POST /api/v1/stations, GET /api/v1/rentals and GET /api/v1/internal/bikes, check the expected result for anonymous, citizen, operator and administrator. Design the test so that adding a new route costs one line, not a method. Hint: @ParameterizedTest with @CsvSource and SecurityMockMvcRequestPostProcessors.user(...).
Exercise 3
A colleague reports that the suite has gone from 45 seconds to 6 minutes after adding twelve test classes. The cache log says [size = 11, hitCount = 3, missCount = 11]. Diagnose the problem, explain what those numbers mean and propose a concrete three-step plan to get back to a single shared context.
Solutions
Solution 1
@WebMvcTest(StationController.class)
class StationControllerTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private StationService stationService;
@Test
void returnsThePaginatedListingWithItsMetadata() throws Exception {
when(stationService.list(any())).thenReturn(new PageResponse<>(
List.of(new StationResponse(1L, "Main Square", "Main Square 1", 24, 9)),
0, 20, 4L, 1, true, true));
mockMvc.perform(get("/api/v1/stations?page=0&size=20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].name").value("Main Square"))
.andExpect(jsonPath("$.totalElements").value(4));
}
@Test
void returns404WithProblemDetailWhenItDoesNotExist() throws Exception {
when(stationService.getById(99L))
.thenThrow(new ResourceNotFoundException("Station", 99L));
mockMvc.perform(get("/api/v1/stations/99"))
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.status").value(404));
}
@Test
void returns400WithoutCallingTheServiceWhenTheCapacityIsNegative() throws Exception {
mockMvc.perform(post("/api/v1/stations").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"X\",\"address\":\"Street 1\",\"capacity\":-5}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(stationService);
}
@Test
void returns409WithTheBusinessCodeWhenTheNameIsDuplicated() throws Exception {
when(stationService.create(any())).thenThrow(new DuplicateStationException("Main Square", 1L));
mockMvc.perform(post("/api/v1/stations").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Main Square\",\"address\":\"Street 1\",\"capacity\":24}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DUPLICATE_STATION"));
}
}Why none of them needs @SpringBootTest: all four verify exclusively what lives in the web layer —route mapping, deserialisation, declarative validation, response serialisation, status codes and the @RestControllerAdvice— and all of that is loaded by @WebMvcTest. What sits underneath is mocked on purpose: the logic of StationService is already tested by unit tests (06-02) and persistence will be tested with @DataJpaTest and Testcontainers. Starting JPA, Hikari, Flyway and security to check a jsonPath would mean paying four seconds per test without catching a single extra failure.
Solution 2
@WebMvcTest
@Import(SecurityConfig.class)
class AccessMatrixTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private StationService stationService;
@MockitoBean private RentalService rentalService;
@MockitoBean private AppUserDetailsService appUserDetailsService;
@ParameterizedTest(name = "{0} {1} as {2} -> {3}")
@CsvSource({
"GET, /api/v1/stations, ANONYMOUS, 200",
"GET, /api/v1/stations, CITIZEN, 200",
"POST, /api/v1/stations, CITIZEN, 403",
"POST, /api/v1/stations, OPERATOR, 403",
"POST, /api/v1/stations, ADMIN, 201",
"GET, /api/v1/rentals, ANONYMOUS, 401",
"GET, /api/v1/rentals, CITIZEN, 200",
"GET, /api/v1/internal/bikes, CITIZEN, 403",
"GET, /api/v1/internal/bikes, OPERATOR, 200",
"GET, /api/v1/internal/bikes, ADMIN, 200"
})
void appliesTheCicloUrbanaAccessMatrix(String verb, String path,
String role, int expected) throws Exception {
var request = "POST".equals(verb)
? post(path).contentType(MediaType.APPLICATION_JSON).content(VALID_BODY)
: get(path);
if (!"ANONYMOUS".equals(role)) {
request = request.with(user("[email protected]").roles(role));
}
mockMvc.perform(request).andExpect(status().is(expected));
}
}Comment: the table IS the security specification, and adding a route costs exactly one line. .with(user(...)) is used instead of @WithMockUser because the role is a parameter and an annotation cannot take one. Note that the if deciding whether to add the user is not the "conditional logic in a test" that 06-02 forbade: it does not choose what to check —the assertion is always the same— but how to build the scenario the row describes. The line separating the two is whether the if affects the assertions.
One final design detail: the 200 and 201 rows are there, not only the 401s and 403s. Without them, a configuration that denied absolutely everything would pass half the table in green.
Solution 3
What the numbers mean. size = 11 is eleven distinct cached contexts; missCount = 11, eleven full start-ups; hitCount = 3, only three reuses. Translated: every test class is starting its own context. At four seconds a start-up, there are your five and a half extra minutes.
Diagnosis. It is not that the tests are slow: it is that their cache keys are all different. The usual causes, in order of frequency: each class declares a different @MockitoBean; some add a @TestPropertySource with one specific property; there are different profiles (@ActiveProfiles("test") in some, nothing or "dev" in others); and some inherited @DirtiesContext destroys the context after every class.
Three-step plan:
- Create a shared base class with
@SpringBootTest,@ActiveProfiles("test")and@AutoConfigureMockMvc, and make all twelve inherit from it. This alone usually cuts eleven contexts down to two or three. - Move every property that today sits in a
@TestPropertySourceintoapplication-test.yml, and remove any@DirtiesContextnot justified in writing. If a test dirties the context, fixing the test —by cleaning up what it creates— is cheaper than rebuilding the whole context. - Reclassify. Review all twelve and ask which genuinely need the full context: those testing only the web layer move to
@WebMvcTest, the query ones to@DataJpaTest, and those testing conditional configuration toApplicationContextRunner. The ones that survive as@SpringBootTestwill share a context thanks to step 1.
And the objective check that it worked: look at the log again. The target is a size of 2 or 3 and a hitCount far higher than the missCount.
Conclusion
CicloUrbana no longer trusts: it checks. You know what an integration test adds over unit tests —the wiring, the annotations and the framework's behaviour, which no mock can see— and what should not be done in one. You know @SpringBootTest and its four web environments, with MOCK as the default and RANDOM_PORT reserved for the few paths that deserve a real Tomcat. And, above all, you understand the context cache: what forms it, what invalidates it —@MockitoBean, @TestPropertySource, @ActiveProfiles, @DirtiesContext— and why a shared base class and a common test profile are the difference between a forty-second suite and a six-minute one that nobody runs.
You have mastered the slices: @WebMvcTest with MockMvc for the public contract —status codes, the Location header, jsonPath, the ProblemDetail of 03-06, the validation of 03-04 and the contract assertion that stops an internal field leaking out—, @DataJpaTest with its embedded database, its automatic rollback and the first-level-cache trap that makes tests pass with wrong SQL when flush and clear are missing, plus @JsonTest, @RestClientTest and the JDBC variants. You know that @MockBean has been deprecated since Spring Boot 3.4 and that its replacement is @MockitoBean, and you know MockMvcTester as the fluent, modern alternative to the classic API.
And you have turned the whole of module 5 into automatic assertions: spring-security-test with @WithMockUser, @WithAnonymousUser, @WithUserDetails, the custom @AsCitizen annotation and the post-processors user(...), jwt() and csrf(); the access matrix of 05-02 written as a parameterised table where adding a route costs one line; and the two tests that close the circle of the course so far: Marta gets a 403 when she tries to finish somebody else's rental and a 200 when she finishes her own, with the complete pair, because a rule that always denies is the most frequent SpEL failure. You know how to configure the test profile, compute properties at run time with @DynamicPropertySource, contribute test-only beans with @TestConfiguration —including the frozen Clock at context scale—, load data with @Sql and verify the conditional configuration of module 2 with ApplicationContextRunner without paying for a single start-up.
One crack remains, and it is a wide one. Everything tested against the database in this lesson has run on H2 in memory, and production is PostgreSQL 16. They are not the same: they differ in dialect, in types, in functions, in the behaviour of sequences and in what they accept as native SQL. The native query of 04-06 that H2 does not understand, the partial index in migration V3, the ddl-auto: validate check against the real schema that V1…V6 of 04-08 leave behind: none of that is verified, and some of those tests pass green today while the code would fail in Ribalta. The next lesson, Testing with Testcontainers, starts a real, ephemeral PostgreSQL in Docker for each suite, connects it with @ServiceConnection and turns that last zone of faith into a zone of assertions.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
