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

  1. What an integration test adds
  2. @SpringBootTest and webEnvironment
  3. The application context cache
  4. Slice tests
  5. @WebMvcTest with MockMvc
  6. Testing errors and validation
  7. MockMvcTester, the fluent API
  8. @DataJpaTest
  9. TestRestTemplate and WebTestClient
  10. Testing the security of module 5
  11. Test-specific configuration
  12. Data with @Sql and ApplicationContextRunner
  13. Common Mistakes and Tips
  14. Exercises

  1. 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.

  1. @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.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class RentalFullFlowIT { /* ... */ }
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.

  1. 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 IntegrationTestBase annotated 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.
  • @DirtiesContext is 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:

logging:
  level:
    org.springframework.test.context.cache: DEBUG

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.

  1. 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.

  1. @WebMvcTest with MockMvc

package 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 (package org.springframework.test.context.bean.override.mockito) replaces the bean in the context. @MockBean has 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 @MockitoSpyBean and @TestBean are also part. Remember from section 3 that every distinct combination of @MockitoBean creates a new context.
  • get(...), post(...), put(...), delete(...) are statics from MockMvcRequestBuilders. It accepts path variables as arguments ("/{id}", 1L), and supports .param(...), .header(...), .contentType(...), .accept(...).
  • status(), jsonPath(), header(), content() are statics from MockMvcResultMatchers. jsonPath uses 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.

  1. 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.

  1. MockMvcTester, the fluent API

Spring 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.

  1. @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 the em.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.

  1. TestRestTemplate and WebTestClient

With 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.

  1. 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.

  1. 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: DEBUG

Remember 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.

  1. Data with @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:

  1. 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.
  2. Move every property that today sits in a @TestPropertySource into application-test.yml, and remove any @DirtiesContext not 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.
  3. 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 to ApplicationContextRunner. The ones that survive as @SpringBootTest will 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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved