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.
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.
@ServiceConnectionremoves almost all@DynamicPropertySourceboilerplate.- Assert on behaviour and contract, not on the number of times a mock was touched.
The test pyramid, applied to Spring
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.
@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
@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
@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.
Practical rules that follow:
- Put shared
@MockitoBeandeclarations 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
@TestConfigurationinner 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
@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?
Should I use H2 instead of a real database in tests?
What replaced @MockBean in recent Spring Boot?
Related tutorials
- Actuator & Observability EndpointsEvery Actuator endpoint worth exposing, writing custom health indicators for Kubernetes probes, adding Micrometer metrics that answer real questions, and securing it all.
- Exception Handling & Error Response DesignA consistent error contract for a Spring Boot API: an exception hierarchy worth having, @ControllerAdvice done properly, RFC 7807 ProblemDetail, and validation errors clients can act on.
- Spring AOP & Aspect-Oriented ProgrammingSpring AOP from pointcut syntax to proxy mechanics: the five advice types, writing annotation-driven aspects, aspect ordering, and why self-invocation silently does nothing.
- Caching Strategies in Spring BootSpring cache abstraction in practice: @Cacheable key design, choosing between Caffeine and Redis, per-cache TTLs, cache stampedes, and a two-level cache that survives a Redis outage.