Module 41 - Spring Testing
Overview
Spring Testing is a layered discipline: every layer of a Spring Boot application has a dedicated, focused test strategy. This module covers the full spectrum - from fast, isolated slices to full-stack integration tests against real infrastructure.
Testing pyramid in Spring Boot
┌──────────────────────────┐
│ @SpringBootTest │ Slow - full context, real HTTP
│ (RANDOM_PORT / MOCK) │
├──────────────────────────┤
│ WireMock │ External HTTP services
├──────────────────────────┤
│ Testcontainers │ Real DB / message broker
├──────────────────────────┤
│ @WebMvcTest │ Web slice - fast (no JPA, no service)
│ @DataJpaTest │ JPA slice - fast (no web, no service)
│ @JsonTest │ JSON only - fastest
└──────────────────────────┘
1. @JsonTest - JSON slice
@JsonTest
class ProductJsonTest {
@Autowired ObjectMapper objectMapper;
JacksonTester<ProductResponse> responseJson;
JacksonTester<ProductRequest> requestJson;
@BeforeEach
void setUp() {
// @JsonTest does NOT auto-wire JacksonTester<T> - init manually
JacksonTester.initFields(this, objectMapper);
}
@Test
void serializes_product_response_to_json() throws Exception {
ProductResponse p = new ProductResponse(1L, "Laptop", new BigDecimal("999.00"), "Electronics", true);
JsonContent<ProductResponse> content = responseJson.write(p);
assertThat(content).hasJsonPathStringValue("$.name", "Laptop");
// Number is Double at runtime - convert for BigDecimal comparison
assertThat(content).extractingJsonPathNumberValue("$.price")
.satisfies(n -> assertThat(new BigDecimal(n.toString())).isEqualByComparingTo("999.00"));
}
}
What @JsonTest loads: Jackson ObjectMapper with all Spring Boot auto-configuration (date/time modules, @JsonInclude, custom serializers from @JsonComponent beans).
What it does NOT load: Spring MVC, JPA, services - startup is sub-100 ms.
JacksonTester.initFields: scans the test instance for JacksonTester<T> fields and binds each to the configured ObjectMapper. This step is required - @JsonTest does not register them as Spring beans.
2. @DataJpaTest - JPA slice
@DataJpaTest
class ProductRepositoryH2Test {
@Autowired ProductRepository productRepository;
@Autowired TestEntityManager entityManager;
@Test
void save_and_find_by_id() {
Product product = Product.builder().name("Laptop").category("Electronics")
.price(new BigDecimal("999.00")).build();
// persist+flush writes the INSERT; clear evicts the L1 cache
entityManager.persistAndFlush(product);
entityManager.clear();
// findById now issues a real SELECT instead of returning the cached object
Product found = productRepository.findById(product.getId()).orElseThrow();
assertThat(found.getName()).isEqualTo("Laptop");
}
}
What @DataJpaTest loads: JPA entities, repositories, TestEntityManager, and an H2 in-memory database (replaces any configured datasource by default).
Transaction rollback: each test runs in a transaction that is rolled back after the test - the database is always clean without explicit cleanup.
TestEntityManager vs repository:
persistAndFlush()- bypasses the repository interface and writes SQL directly; useful to set up test data without testing the repository itselfclear()- evicts the first-level (L1) cache so the nextfindByIdhits the database
3. @WebMvcTest - web layer slice
@WebMvcTest(ProductController.class)
class ProductControllerSliceTest {
@Autowired MockMvc mockMvc;
@MockBean ProductService productService;
@Test
void getById_not_found_returns_404() throws Exception {
given(productService.findById(99L)).willThrow(new ProductNotFoundException(99L));
mockMvc.perform(get("/api/products/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Product Not Found"));
}
}
What @WebMvcTest loads: the named controller, @ControllerAdvice, Jackson, Spring MVC configuration - nothing else.
@MockBean: replaces the service bean in the application context with a Mockito mock. BDDMockito.given() sets the stub behaviour.
Benefit over @SpringBootTest: startup in ~400 ms instead of 2+ s; a broken persistence layer cannot fail controller tests; forces clear separation of responsibilities.
4. @DataJpaTest + Testcontainers - real PostgreSQL
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // (1)
@Testcontainers(disabledWithoutDocker = true) // (2)
class ProductRepositoryTCTest {
@Container
@ServiceConnection // (3)
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired ProductRepository productRepository;
@BeforeEach
void cleanup() {
productRepository.deleteAll(); // no automatic rollback from @DataJpaTest here
}
}
(1) replace = NONE: prevents @DataJpaTest from substituting the datasource with H2. The datasource now comes from Testcontainers.
(2) disabledWithoutDocker = true: the test suite skips gracefully on machines where Docker is not running instead of throwing an exception.
(3) @ServiceConnection (Spring Boot 3.1+): reads host, port, database, username, and password from the running container and auto-registers them as DataSource connection details - no manual @DynamicPropertySource needed for standard containers.
Static @Container: the container starts once before the first test in the class and stops after the last - all tests share the same PostgreSQL instance. Because @DataJpaTest transaction rollback does not apply with an external container (since the commit happens before the assertion in some scenarios), a @BeforeEach cleanup calls deleteAll() to ensure a clean slate.
Why use Testcontainers at all? H2 is convenient but its SQL dialect diverges from PostgreSQL: window functions, RETURNING, JSON operators, custom types, and index behaviour may all differ. Testcontainers tests run against the exact engine used in production - catching dialect-specific bugs before they reach staging.
5. WireMock - HTTP stub server
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class PricingClientWireMockTest {
@RegisterExtension // (1)
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@DynamicPropertySource // (2)
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("pricing.service.url", wireMock::baseUrl);
}
@Autowired PricingClient pricingClient;
@Test
void getPrice_service_unavailable_returns_zero_fallback() {
wireMock.stubFor(get(urlEqualTo("/prices/2"))
.willReturn(aResponse().withStatus(503)));
BigDecimal price = pricingClient.getPrice(2L);
assertThat(price).isEqualByComparingTo("0"); // fallback branch
}
@Test
void getPrice_verifies_request_sent_to_correct_endpoint() {
wireMock.stubFor(get(urlEqualTo("/prices/42"))
.willReturn(okJson("{\"productId\":42,\"price\":149.99}")));
pricingClient.getPrice(42L);
wireMock.verify(1, getRequestedFor(urlEqualTo("/prices/42")));
}
}
(1) @RegisterExtension static WireMockExtension: starts a Jetty server on a random port before all tests; resets stubs between tests; stops after all tests.
(2) @DynamicPropertySource: runs after the WireMock extension starts (so wireMock.baseUrl() is available) but before the Spring context is created - this is the correct seam for injecting the random port. The method reference wireMock::baseUrl is a Supplier<String> called when the property is resolved, not immediately.
webEnvironment = NONE: no web server is started - only the beans needed to wire PricingClient (and its dependencies) are loaded, making the test fast.
WireMock vs @MockBean:
| Aspect | @MockBean | WireMock |
|---|---|---|
| What is mocked | Java object | Real HTTP server |
| Real HTTP happens | No | Yes |
| Tests URL construction | No | Yes |
| Tests headers/serialization | No | Yes |
| Tests timeout/retry config | No | Yes |
| Catches JSON field name bugs | No | Yes |
6. @SpringBootTest - full integration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductApiIntegrationTest {
@Autowired TestRestTemplate restTemplate;
@MockBean PricingClient pricingClient; // external service - not the subject under test
@Test
void create_then_retrieve_roundtrip() {
ResponseEntity<ProductResponse> created = restTemplate.postForEntity(
"/api/products",
new ProductRequest("Laptop", new BigDecimal("999.00"), "Electronics"),
ProductResponse.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
ResponseEntity<ProductResponse> retrieved = restTemplate.getForEntity(
"/api/products/" + created.getBody().id(), ProductResponse.class);
assertThat(retrieved.getBody().name()).isEqualTo("Laptop");
}
}
RANDOM_PORT: starts the embedded Tomcat on a random free port; TestRestTemplate is auto-configured to call it over TCP - the full servlet container, filter chain, and connection handling are exercised.
No automatic rollback: @SpringBootTest does not wrap tests in rolled-back transactions. Each test must create its own data and not depend on cross-test state.
TestRestTemplate vs MockMvc:
MockMvc- in-process; does not start a real port; faster for slice testsTestRestTemplate- real TCP; exercises the container layer; needed forRANDOM_PORTtests
Spring context caching
Spring reuses a loaded context for all tests that share the same configuration key. Anything that changes the key forces a new context: @MockBean, @DynamicPropertySource, different @ActiveProfiles, different properties files.
| Annotation | Context scope |
|---|---|
@SpringBootTest | Full context (heaviest) |
@WebMvcTest | Web slice |
@DataJpaTest | JPA slice |
@JsonTest | ObjectMapper only |
@DynamicPropertySource | New context per unique value set |
Keep the number of distinct context configurations low - each unique combination triggers a fresh Spring startup.
Key takeaways
- Match test granularity to the layer under test:
@JsonTestfor serialization,@DataJpaTestfor repositories,@WebMvcTestfor controllers, WireMock for HTTP clients, Testcontainers for DB-dialect-specific behaviour,@SpringBootTest(RANDOM_PORT)for the full stack. @DataJpaTestrolls back automatically - no cleanup needed.@SpringBootTestdoes not.- Use
@ServiceConnectionwith Testcontainers to eliminate manual@DynamicPropertySourceboilerplate for standard containers. - WireMock tests what
@MockBeancannot: URL construction, headers, serialization, timeouts, and retry logic all run as in production. @DynamicPropertySourceruns after extensions but before context creation - the correct seam for injecting dynamic infrastructure coordinates (ports, connection strings) into Spring.