04-advanced-spring

Testing Spring Boot: Slices, Mocks and Real Infrastructure

Which test annotation to reach for, why @SpringBootTest everywhere makes a slow suite, and how Testcontainers replaced H2 for repository tests.

October 9, 2026
spring-boottestingSpringBootTestWebMvcTestDataJpaTestMockMvctestcontainersjunit

Context Startup Is the Cost

A Spring test's dominant cost is building the ApplicationContext. A full one — every bean, a connection pool, an embedded web server — takes seconds. Multiply by a few hundred tests and you have a suite nobody runs before pushing, which means it stops catching anything.

Spring's answer is test slices: annotations that build a deliberately partial context containing only the layer under test. Choosing the right one is most of what makes a Spring test suite fast.

The second thing worth knowing up front: Spring caches contexts between test classes, keyed on configuration. Two classes with identical setup share one context; vary one property and you get a second. A suite with twelve slightly different configurations pays twelve startups. Keeping configurations uniform is a real optimisation.

No Spring At All

The fastest test builds no context:

java
class OrderTotalTest {
 
    @Test
    void appliesBulkDiscountOverTenUnits() {
        var calculator = new OrderTotalCalculator(new FlatRateTaxPolicy(0.20));
        var total = calculator.total(new OrderLine("ABC-123456", 12, BigDecimal.TEN));
        assertThat(total).isEqualByComparingTo("108.00");
    }
}

Milliseconds, no annotations. This is available because the class takes its dependencies in a constructor — the testability argument from phase 1, cashed in.

Most business logic should be testable this way. If testing a rule requires booting Spring, the rule is probably entangled with infrastructure that it doesn't need.

@WebMvcTest — The Web Layer

Builds the MVC infrastructure and your controllers. No services, no repositories, no database.

java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
 
    @Autowired MockMvc mockMvc;
    @MockitoBean OrderService orderService;        // collaborators must be mocked
 
    @Test
    void returnsOrderWhenFound() throws Exception {
        given(orderService.findById(42L))
                .willReturn(new OrderResponse(42L, "Acme", new BigDecimal("99.00")));
 
        mockMvc.perform(get("/api/orders/42"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.customerName").value("Acme"));
    }
 
    @Test
    void returns400WhenQuantityIsNegative() throws Exception {
        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                 { "customerName": "Acme", "quantity": -5 }
                                 """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("quantity"));
    }
 
    @Test
    void returns404WithProblemDetailWhenMissing() throws Exception {
        given(orderService.findById(99L)).willThrow(new OrderNotFoundException(99L));
 
        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentType("application/problem+json"))
                .andExpect(jsonPath("$.title").value("Order not found"));
    }
}

This is the right place to test everything phase 2 covered: status codes, request binding, validation messages, and the error format from your @ControllerAdvice — advice classes are included in the slice, so they're exercised properly.

Naming the controller — @WebMvcTest(OrderController.class) — restricts the slice to one. Without it, every controller loads, and every one of their dependencies needs mocking.

⚠️

@MockBean and @SpyBean are deprecated as of Spring Boot 3.4 in favour of @MockitoBean and @MockitoSpyBean, which moved into Spring Framework proper. The roadmap spec still names the old ones; use the new ones in new code. Behaviour is equivalent — note that both still replace a bean definition, which creates a distinct context cache entry per unique set of mocks.

@DataJpaTest — The Persistence Layer

Builds JPA, your entities and repositories. No web layer, no services. Each test runs in a transaction that rolls back.

java
@DataJpaTest
class OrderRepositoryTest {
 
    @Autowired OrderRepository repository;
    @Autowired TestEntityManager em;
 
    @Test
    void findsByStatus() {
        em.persist(new Order("Acme", new BigDecimal("50.00"), OrderStatus.PAID));
        em.persist(new Order("Globex", new BigDecimal("75.00"), OrderStatus.NEW));
        em.flush();
 
        assertThat(repository.findByStatus(OrderStatus.PAID))
                .extracting(Order::getCustomerName)
                .containsExactly("Acme");
    }
}

em.flush() matters: without it, Hibernate may not have written the inserts when your query runs, so the test fails for a reason unrelated to the code. TestEntityManager also offers clear(), which detaches everything — useful for verifying that a query really loads what you think rather than returning the instance already in the persistence context.

This slice is where you verify derived queries parse, that a fetch join works, and — with a query counter — that an N+1 hasn't reappeared.

Use a real database

@DataJpaTest historically meant H2, and the roadmap mentions an in-memory DB. H2's problem is that it isn't the database you ship:

  • jsonb, array types, insert ... on conflict, window functions, date_trunc — all differ or are missing
  • Identifier casing, type coercion and constraint-violation messages differ
  • Your Flyway migrations may not even run

So a green test suite is compatible with a broken deployment. Testcontainers removes the gap:

java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
 
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
}

Replace.NONE stops Spring substituting an embedded database; @ServiceConnection wires the container's connection details in automatically. Declaring the container static means one Postgres per class — reused across its tests, not restarted per test.

✅

Put the container in a shared abstract base class so every repository test reuses one container and one cached context for the whole suite. Spun up per class, Postgres startup dominates your test time; shared, it's a few seconds once. This single change is usually the difference between Testcontainers feeling fast or unbearable.

The Other Slices

AnnotationBuildsFor
@WebMvcTestMVC + controllers + adviceController behaviour
@DataJpaTestJPA + repositoriesQueries and mapping
@JdbcTestDataSource + JdbcClientSQL from the JDBC guide
@RestClientTestRestClient/RestTemplate buildersOutbound HTTP clients
@JsonTestJackson onlySerialisation shape
@DataRedisTestRedis supportCache repositories

@RestClientTest deserves a mention, because outbound clients usually go untested:

java
@RestClientTest(InventoryService.class)
class InventoryServiceTest {
 
    @Autowired InventoryService service;
    @Autowired MockRestServiceServer server;
 
    @Test
    void mapsStockResponse() {
        server.expect(requestTo("/stock/ABC-123456"))
              .andRespond(withSuccess("""
                        { "sku": "ABC-123456", "available": 7 }
                        """, MediaType.APPLICATION_JSON));
 
        assertThat(service.stockFor("ABC-123456").available()).isEqualTo(7);
    }
 
    @Test
    void mapsServerErrorToDomainException() {
        server.expect(requestTo("/stock/ABC-123456"))
              .andRespond(withServerError());
 
        assertThatThrownBy(() -> service.stockFor("ABC-123456"))
                .isInstanceOf(InventoryUnavailableException.class);
    }
}

That second test is the valuable one — it verifies the error mapping from the outbound HTTP guide, which is exactly the path that only executes when a dependency is already failing.

@SpringBootTest — The Whole Application

The full context, and the slowest option:

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderFlowIntegrationTest {
 
    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
 
    @Autowired TestRestTemplate rest;
 
    @Test
    void placesAnOrderEndToEnd() {
        var response = rest.postForEntity("/api/orders",
                new NewOrderRequest("Acme", "ABC-123456", 2), OrderResponse.class);
 
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getHeaders().getLocation()).isNotNull();
    }
}

Worth its cost for a handful of tests that prove the pieces fit: a real HTTP request, through real security, to a real database. Not worth it for the fiftieth variation of a validation rule.

webEnvironment options: MOCK (default — no server, use MockMvc), RANDOM_PORT (real server, avoids collisions in CI), DEFINED_PORT, NONE.

⚠️

Avoid @SpringBootTest with @Transactional as a convenience for rollback. It keeps a transaction open around the test, which hides exactly the boundary bugs the transactions guide warned about — lazy loading that only works because the test's transaction never closed. Worse, with RANDOM_PORT the server handles the request on a different thread with its own transaction, so the test's rollback doesn't cover the data the request wrote. Clean up explicitly instead.

Testing With Security On

Once security is configured, unauthenticated tests get 401s:

java
@Test
@WithMockUser(roles = "ADMIN")
void adminCanCancelAnyOrder() throws Exception {
    mockMvc.perform(delete("/api/orders/42")).andExpect(status().isNoContent());
}
 
@Test
void anonymousIsRejected() throws Exception {
    mockMvc.perform(delete("/api/orders/42")).andExpect(status().isUnauthorized());
}
 
@Test
void userWithoutAdminRoleIsForbidden() throws Exception {
    mockMvc.perform(delete("/api/orders/42").with(user("bob").roles("USER")))
            .andExpect(status().isForbidden());
}

Add spring-security-test for @WithMockUser and the request post-processors. The second and third tests are the ones people skip and the ones that matter — a rule nobody tested in the negative is a rule you haven't verified. Asserting 403 for an under-privileged user is how you catch a misordered authorizeHttpRequests chain.

Check yourself

A suite of 200 tests all use @SpringBootTest and takes 11 minutes. Most assert validation rules and controller status codes. What is the highest-value change?

Parameterised Tests

For a rule with many inputs, one test method beats twelve near-duplicates:

java
@ParameterizedTest
@CsvSource({
        "'',          1,  customerName, must not be blank",
        "Acme,       -1,  quantity,     must be greater than or equal to 1",
        "Acme,     1001,  quantity,     must be less than or equal to 1000"
})
void rejectsInvalidRequests(String name, int quantity, String field, String message) { }
 
@ParameterizedTest
@EnumSource(OrderStatus.class)
void everyStatusSerialisesAsItsName(OrderStatus status) { }
 
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { " ", "\t" })
void treatsBlankNamesAsInvalid(String name) { }

@EnumSource is quietly valuable: add an enum constant and the test automatically covers it, so a new status can't be forgotten.

A Suite Shape That Works

text
Many   plain JUnit             — business rules, no Spring
Some   @WebMvcTest             — controllers, validation, error format
Some   @DataJpaTest            — queries, mapping, query counts
Few    @RestClientTest         — outbound clients, especially failures
Fewest @SpringBootTest         — a handful of end-to-end paths

The familiar pyramid, expressed in Spring annotations. The common failure is an inverted one: everything at the bottom tier, a slow suite, and — because full-context tests are awkward to write — poor coverage of the edge cases that slices make easy.

The Mental Model, Restated

  1. Context startup is the cost. Slices exist to build less of it, and contexts are cached by configuration.
  2. Most logic needs no Spring. Constructor injection is what makes that possible.
  3. @WebMvcTest for the web layer — status codes, binding, validation, and your advice's error format.
  4. @DataJpaTest against real Postgres via Testcontainers, with the container in a shared base class. H2 tests a database you don't ship.
  5. @MockitoBean, not the deprecated @MockBean.
  6. @SpringBootTest sparingly, and not with @Transactional for convenience.
  7. Test authorisation in the negative — assert 401 and 403, not just the happy path.

What's Next

Three times now the same mechanism has appeared: @Transactional, @Cacheable and @PreAuthorize all work by proxying your beans, and all three break on self-invocation. The next guide covers the general case — Spring AOP — which explains the pattern, lets you write your own cross-cutting behaviour, and makes the recurring limitation finally make sense.