Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Spring Boot Testing Masterclass

A test strategy that stays fast: when to use @SpringBootTest versus a slice, real databases with Testcontainers and @ServiceConnection, stubbing HTTP with WireMock, and context caching.

Intermediate6 min readUpdated
On this page

A Spring test suite decays in a predictable way: someone needs one more bean, adds @SpringBootTest, and within a year the whole suite loads the entire application for every assertion. This guide is about choosing the smallest test that proves the thing, and making the ones that must be big run once.

Key Takeaways

  • Pick the narrowest slice that exercises the code: plain JUnit, then a slice, then the full context.
  • Spring caches contexts by configuration; every unique combination of mocks and properties costs another startup.
  • Use Testcontainers, not H2 — dialect differences are exactly where production bugs hide.
  • @ServiceConnection removes almost all @DynamicPropertySource boilerplate.
  • Assert on behaviour and contract, not on the number of times a mock was touched.

The test pyramid, applied to Spring

Cost and confidence both rise as you climb. Most assertions belong in the bottom two layers.

Business logic that takes inputs and returns outputs needs no Spring at all. If a class can only be tested with a running context, that is usually a design signal — the logic is entangled with infrastructure and wants extracting.

Slices

Each slice starts a context containing only the beans for one layer.

OrderControllerTest.java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
 
    @Autowired MockMvc mvc;
    @MockitoBean OrderService orderService;    // the whole service layer is mocked
 
    @Test
    void returns404WhenOrderMissing() throws Exception {
        given(orderService.find("nope")).willThrow(new OrderNotFoundException("nope"));
 
        mvc.perform(get("/api/v1/orders/nope"))
           .andExpect(status().isNotFound())
           .andExpect(jsonPath("$.title").value("Order not found"))
           .andExpect(jsonPath("$.status").value(404));
    }
 
    @Test
    void rejectsNegativeQuantity() throws Exception {
        mvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"sku":"ABC-1","quantity":-3}
                    """))
           .andExpect(status().isUnprocessableEntity())
           .andExpect(jsonPath("$.errors[0].field").value("quantity"));
    }
}

@WebMvcTest loads controllers, @ControllerAdvice, converters and filters — not services or repositories. It is the right place to test status codes, validation, serialisation and error mapping, and the wrong place to test business rules.

The other slices worth knowing: @DataJpaTest (repositories plus an in-memory or configured database, each test rolled back), @JsonTest (serialisation only), @RestClientTest (outbound HTTP clients with MockRestServiceServer), and @WebFluxTest for reactive controllers.

Real infrastructure with Testcontainers

OrderRepositoryTest.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
 
    // static: one container for the whole class, reused across every test method
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
 
    @Autowired OrderRepository repository;
 
    @Test
    void findsByStatusOrderedByCreationDate() {
        repository.saveAll(List.of(
            order("A", Status.PENDING, Instant.parse("2026-01-01T00:00:00Z")),
            order("B", Status.PENDING, Instant.parse("2026-01-02T00:00:00Z")),
            order("C", Status.SHIPPED, Instant.parse("2026-01-03T00:00:00Z"))));
 
        var pending = repository.findByStatusOrderByCreatedAtDesc(Status.PENDING);
 
        assertThat(pending).extracting(Order::reference).containsExactly("B", "A");
    }
}

@ServiceConnection is the piece that removes the boilerplate. Before it, you needed a @DynamicPropertySource block mapping the container's random port onto spring.datasource.url. Now Boot inspects the container type and wires the connection details itself. It works for PostgreSQL, MySQL, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch and more.

To share one container across many test classes, declare it in an abstract base class with a static initialiser and withReuse(true), plus testcontainers.reuse.enable=true in ~/.testcontainers.properties. The container then survives between runs locally, which turns a 15-second startup into nothing.

Stubbing outbound HTTP

ShippingClientTest.java
@SpringBootTest
class ShippingClientTest {
 
    static WireMockServer wireMock = new WireMockServer(options().dynamicPort());
 
    @BeforeAll static void start() { wireMock.start(); }
    @AfterAll  static void stop()  { wireMock.stop(); }
 
    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("app.shipping.base-url", wireMock::baseUrl);
    }
 
    @Autowired ShippingClient client;
 
    @Test
    void retriesOnceOnGatewayTimeout() {
        wireMock.stubFor(post(urlEqualTo("/v2/labels"))
                .inScenario("retry").whenScenarioStateIs(STARTED)
                .willReturn(aResponse().withStatus(504))
                .willSetStateTo("second"));
 
        wireMock.stubFor(post(urlEqualTo("/v2/labels"))
                .inScenario("retry").whenScenarioStateIs("second")
                .willReturn(okJson("""
                    {"trackingNumber":"TRK-9","carrier":"DHL"}
                    """)));
 
        var label = client.createLabel(new Shipment("ORD-1", "DE"));
 
        assertThat(label.trackingNumber()).isEqualTo("TRK-9");
        wireMock.verify(2, postRequestedFor(urlEqualTo("/v2/labels")));
    }
}

Testing the retry against a stub proves the resilience configuration actually works — something a mocked client can never show you, because a mock has no notion of an HTTP status.

Context caching, the thing that decides your suite's runtime

Spring caches an ApplicationContext keyed by its full configuration: the classes, active profiles, property sources, and the set of mocked beans. Two tests with identical configuration share one context. Change anything and you pay for another startup.

Each distinct configuration builds and caches its own context. Minimising distinct configurations is the single biggest lever on suite runtime.

Practical rules that follow:

  • Put shared @MockitoBean declarations and container setup in one abstract base class so every integration test resolves to the same cache key.
  • Avoid @DirtiesContext. It evicts the cache entry and forces a rebuild. If state leaks between tests, clean the state, not the context.
  • Prefer @TestConfiguration inner classes over ad-hoc property overrides where possible.

What to assert, and what not to

A test suite's value is not in its line coverage but in how reliably a failure means something is actually broken. The fastest way to destroy that property is to assert on implementation details.

Assert on observable behaviour: the status code, the response body, the row that ended up in the database, the message that landed on the queue. These are the things a caller depends on, so a change that breaks them is a change that breaks someone. Avoid asserting that a mock was called exactly twice, or that a private helper ran — those assertions fail on every refactoring that changes nothing a user could notice, and after the third false alarm people start deleting tests instead of reading them.

There is one important exception. When the interaction itself is the contract — an idempotency guarantee, a retry policy, a rule that a payment provider must never be charged twice — verifying call counts is exactly right, because the count is the behaviour.

Be similarly careful with test data. A test that constructs a fifty-field object to assert on one of them buries its own intent. Use builders or object mothers that supply sane defaults, and set only the fields the test is actually about. A reader should be able to see the point of the test in the three lines that differ from the default.

Finally, resist the urge to share mutable state between test methods. Static fields holding half-built objects create ordering dependencies that pass locally and fail on CI where JUnit parallelises. Containers are the one legitimate exception, because they are expensive and immutable once started.

Asynchronous assertions

EventualAssertions.java
@Test
void publishesOrderPlacedEvent() {
    orderService.place(new Order("SKU-1", 2));
 
    await().atMost(Duration.ofSeconds(5))
           .pollInterval(Duration.ofMillis(100))
           .untilAsserted(() ->
               assertThat(eventStore.findByType("OrderPlaced")).hasSize(1));
}

Awaitility replaces Thread.sleep with a bounded poll. A sleep is either too short and flaky or too long and slow; a poll finishes as soon as the condition holds.

What to take away

Start every test at the bottom of the pyramid and move up only when the thing you need to prove genuinely requires it. Use real databases through Testcontainers, stub the network with WireMock, and treat context configuration as a scarce resource — because the number of distinct configurations is what your suite's runtime is made of.

Frequently Asked Questions

Why is my test suite so slow?
Almost always context caching. Spring caches an ApplicationContext per unique configuration, so every distinct combination of @MockBean, @TestPropertySource, active profiles or @SpringBootTest attributes builds a new one. Keep the number of distinct configurations small and the suite starts a handful of contexts instead of dozens.
Should I use H2 instead of a real database in tests?
No. H2 differs from PostgreSQL in null ordering, JSON support, upsert syntax, sequence behaviour and locking semantics, so tests pass against H2 and fail in production. Testcontainers gives you the real engine for a few seconds of startup, reused across the whole suite.
What replaced @MockBean in recent Spring Boot?
@MockitoBean and @MockitoSpyBean, from the core spring-test module. They behave the same way but are not tied to the Boot test module. @MockBean still works and is deprecated rather than removed, so migrate when convenient.

Related tutorials