Testing Spring Applications
Boot's killer testing feature is test slices—load only the beans you need, not the entire application context. Combine slices with MockMvc for web tests, Testcontainers for real databases, and WireMock for external HTTP— fast feedback without sacrificing confidence.
Test slices — Boot's best testing feature
A full @SpringBootTest loads every auto-configuration, every bean, and every integration. Test slices load only the infrastructure for one layer—controllers, JPA, Redis, JSON serializers—so tests start in milliseconds instead of seconds.
flowchart TB
subgraph full["@SpringBootTest — full context"]
A[Controllers] --> B[Services]
B --> C[Repositories]
C --> D[(Database)]
B --> E[Redis / Kafka / …]
end
subgraph slice["@WebMvcTest — web slice only"]
F[Controllers] -.->|@MockBean| G[Services mocked]
end
subgraph jpa["@DataJpaTest — persistence slice"]
H[Repositories] --> I[(Embedded H2)]
end
@SpringBootTest — full application context
Use when you need end-to-end confidence: multiple layers wired together, real property binding, or a smoke test that the app starts. It is the slowest option—treat it as integration coverage, not your default unit test.
@SpringBootTest
class ApplicationContextLoadsTest {
@Test void contextLoads() {}
}
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {
@Autowired TestRestTemplate rest;
@Test void createOrder_returns201() {
var response = rest.postForEntity("/api/orders", newOrder(), OrderDto.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
}
When to use: cross-layer flows (controller → service → repository), verifying auto-configuration wiring, or testing with a real embedded server (RANDOM_PORT / DEFINED_PORT).
When to avoid: testing a single controller's JSON mapping, a repository query, or serializer output—slices are faster and equally precise.
@WebMvcTest — web layer only
Loads @Controller / @RestController beans, Spring MVC infrastructure, and Jackson— not JPA, Redis, or your service implementations. Collaborators are replaced with @MockBean (see MockBean vs SpyBean). Pairs naturally with MockMvc from the Web MVC chapter.
@WebMvcTest(OrderController.class) // only this controller (+ @ControllerAdvice if needed)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService; // service not in context — must mock
@Test void getOrder_found() throws Exception {
when(orderService.findById(1L)).thenReturn(new OrderDto(1L, "OPEN"));
mockMvc.perform(get("/api/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("OPEN"));
}
}
@DataJpaTest — JPA layer only
Configures an in-memory database (typically H2), JPA, and TestEntityManager. Only @Entity classes and Spring Data repositories are loaded—no web layer, no @Service beans. Tests run inside a transaction that rolls back by default, so each test starts with a clean schema.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // keep real datasource if using Testcontainers
class OrderRepositoryTest {
@Autowired OrderRepository orders;
@Autowired TestEntityManager em;
@Test void findByStatus_returnsMatching() {
em.persist(new Order("SHIPPED"));
em.persist(new Order("OPEN"));
em.flush();
assertThat(orders.findByStatus("OPEN")).hasSize(1);
}
}
@DataJpaTest does not load custom @Configuration classes unless you @Import them. Repository custom impls, auditing listeners, and entity callbacks may need explicit imports.
Other slices
| Annotation | Loads | Typical use |
|---|---|---|
| @DataRedisTest | Redis + StringRedisTemplate / RedisTemplate | Cache keys, pub/sub serializers (Caching) |
| @DataMongoTest | MongoDB + MongoTemplate | Document repositories, aggregation queries |
| @JsonTest | Jackson ObjectMapper only | Serialize/deserialize DTOs without MVC |
| @RestClientTest | RestTemplate / RestClient + MockRestServiceServer | Outbound HTTP client code |
@JsonTest
class OrderDtoJsonTest {
@Autowired JacksonTester<OrderDto> json;
@Test void serialize() throws Exception {
assertThat(json.write(new OrderDto(1L, "OPEN")))
.isEqualToJson("{\"id\":1,\"status\":\"OPEN\"}");
}
}
@RestClientTest(PaymentClient.class)
class PaymentClientTest {
@Autowired PaymentClient client;
@Autowired MockRestServiceServer server;
@Test void charge_postsToGateway() {
server.expect(requestTo("/charge"))
.andRespond(withSuccess("{\"ok\":true}", MediaType.APPLICATION_JSON));
assertThat(client.charge(100)).isTrue();
}
}
Follow the testing pyramid: many fast slice tests at the base, fewer @SpringBootTest integration tests at the top. A suite of 200 @WebMvcTest classes often runs in under 30 seconds; 200 full-context tests can take minutes.
Why slices are faster and more focused
- Fewer beans — no Kafka listeners, security filter chains, or scheduled tasks unless the slice includes them.
- Targeted auto-config — Boot imports only *AutoConfiguration relevant to the slice (e.g. WebMvcTestAutoConfiguration).
- Clear failure signals — a failing @WebMvcTest points at the controller layer; a failing full test could be anywhere.
- Context cache hits — identical slice configurations reuse the same ApplicationContext (see context caching).
"What's the difference between @WebMvcTest and @SpringBootTest(webEnvironment = RANDOM_PORT)?" — @WebMvcTest uses MockMvc in-process (no real HTTP server, services mocked). RANDOM_PORT boots Tomcat/Netty and hits real HTTP—slower, but catches servlet-filter and port-binding issues.
MockMvc — test controllers without a browser
MockMvc simulates HTTP requests against the Spring MVC dispatcher servlet in the same JVM. No network, no browser—just fluent assertions on status, headers, and JSON body.
@AutoConfigureMockMvc
Included automatically in @WebMvcTest. On a full @SpringBootTest, add @AutoConfigureMockMvc to inject a configured MockMvc bean.
@SpringBootTest
@AutoConfigureMockMvc
class FullStackMvcTest {
@Autowired MockMvc mockMvc;
}
perform() — HTTP verbs
Static imports from MockMvcRequestBuilders and MockMvcResultMatchers keep tests readable.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.hamcrest.Matchers.*;
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk());
mockMvc.perform(get("/api/orders/42"))
.andExpect(status().isNotFound());
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sku\":\"ABC\",\"qty\":2}"))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/api/orders/")));
mockMvc.perform(put("/api/orders/1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"SHIPPED\"}"))
.andExpect(status().isOk());
mockMvc.perform(delete("/api/orders/1"))
.andExpect(status().isNoContent());
andExpect() — assertions
| Matcher | Example |
|---|---|
| status() | .andExpect(status().isUnauthorized()) |
| content() | .andExpect(content().string(containsString("error"))) |
| jsonPath() | .andExpect(jsonPath("$.items", hasSize(3))) |
| header() | .andExpect(header().exists("X-Request-Id")) |
| cookie() | .andExpect(cookie().value("SESSION", notNullValue())) |
| redirectedUrl() | .andExpect(redirectedUrl("/login")) |
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.content", hasSize(greaterThan(0))))
.andExpect(jsonPath("$.content[0].id").isNumber())
.andExpect(jsonPath("$.content[?(@.status=='OPEN')]").exists())
.andExpect(jsonPath("$.totalElements").value(42));
MockMvcResultHandlers.print() — debugging
Chain .andDo(print()) to dump request and response to stdout—indispensable when a test fails with an unexpected 500.
mockMvc.perform(post("/api/orders").content("{bad json"))
.andDo(print()) // shows status, headers, body
.andExpect(status().isBadRequest());
Combine MockMvc with @WithMockUser / @WithUserDetails from Spring Security Test to exercise secured endpoints without a real JWT—covered in depth in Spring Security.
@MockBean vs @SpyBean
Both replace or wrap beans inside the test ApplicationContext—unlike Mockito's @Mock, which lives outside Spring.
| Annotation | Behavior | Use when |
|---|---|---|
| @MockBean | Replaces the bean with a Mockito mock (all methods stubbed to defaults) | Collaborator not under test—OrderService in a controller test |
| @SpyBean | Wraps the real bean; call real methods unless stubbed | Partial mocking—verify one method was called but let others run |
@WebMvcTest(CheckoutController.class)
class CheckoutControllerTest {
@MockBean PaymentGateway gateway; // fully mocked external dependency
@SpyBean PricingService pricing; // real pricing logic, stub only discount()
@Test void checkout_appliesDiscount() throws Exception {
doReturn(BigDecimal.valueOf(9.99)).when(pricing).applyDiscount(any());
when(gateway.charge(any())).thenReturn(PaymentResult.ok());
mockMvc.perform(post("/api/checkout").contentType(APPLICATION_JSON).content(body))
.andExpect(status().isOk());
verify(gateway).charge(argThat(c -> c.amount().equals(new BigDecimal("9.99"))));
}
}
@MockBean / @SpyBean change the context definition and can invalidate context caching if overused with different bean sets per class. Prefer one consistent mock layout per test class.
Testcontainers — real infrastructure in Docker
Embedded H2 does not catch PostgreSQL-specific SQL, index behavior, or JSON operators. Testcontainers spins up real database, Redis, and Kafka instances in Docker—disposable, isolated, and closer to production.
Dependencies:
testImplementation "org.springframework.boot:spring-boot-testcontainers"
testImplementation "org.testcontainers:junit-jupiter"
testImplementation "org.testcontainers:postgresql"
testImplementation "org.testcontainers:kafka"
@Testcontainers and @Container
@Testcontainers
@SpringBootTest
class OrderRepositoryPostgresTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("orders")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void registerProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired OrderRepository orders;
@Test
@Transactional
void nativeQuery_usesPostgresJson() {
// exercises real PG jsonb operators — H2 would not catch bugs here
}
}
@DynamicPropertySource
Injects runtime values from containers into Spring's Environment before context refresh. Use method references (postgres::getJdbcUrl) so values resolve after the container starts. Overrides application-test.yml for connection URLs and ports.
Static @Container — reusable across tests
Mark the container static so JUnit starts it once per test class (or use a singleton base class for the whole module). Non-static containers restart per test method—slower but maximum isolation.
abstract class IntegrationTestBase {
@Container
static final GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Container
static final KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.data.redis.host", redis::getHost);
r.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
r.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
}
@SpringBootTest
class CacheIntegrationTest extends IntegrationTestBase {
@Test void cacheRoundTrip() { /* real Redis */ }
}
Testcontainers uses Ryuk (a sidecar container) to reap orphaned containers after the JVM exits. CI runners need Docker socket access. On Apple Silicon, use multi-arch images (postgres:16-alpine) to avoid pull failures.
@ServiceConnection (Spring Boot 3.1+)
Boot can wire container endpoints automatically—no manual @DynamicPropertySource for supported technologies. Add spring-boot-testcontainers and annotate the container field.
@Testcontainers
@SpringBootTest
class OrderServiceIT {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
// Boot sets spring.datasource.* automatically from @ServiceConnection
@Autowired OrderService orders;
@Test void endToEnd() { /* ... */ }
}
@ServiceConnection supports PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Kafka, RabbitMQ, and more as of Boot 3.2+. Check org.springframework.boot.testcontainers.service.connection for the current list.
"When would you use Testcontainers vs H2?" — H2 for fast repository unit tests and CI without Docker. Testcontainers when SQL dialect, extensions (jsonb, GIS), or driver behavior must match production—especially before releases and for migration validation.
WireMock — stub external HTTP services
Your service calls payment gateways, identity providers, and partner APIs. WireMock stands in for those endpoints with programmable stubs—no flaky dependence on third-party sandboxes.
Add the WireMock Spring Boot integration or use the JUnit 5 extension directly:
testImplementation "org.wiremock.integrations:wiremock-spring-boot:3.0.0"
@WireMockTest
Starts an embedded WireMock server and can inject its base URL into Spring properties via @ConfigureWireMock.
@SpringBootTest
@WireMockTest(httpPort = 9561)
class PaymentIntegrationTest {
@Autowired PaymentClient paymentClient;
@Test
void charge_delegatesToGateway() {
stubFor(post(urlEqualTo("/v1/charges"))
.withRequestBody(containing("\"amount\":100"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"ch_123\",\"status\":\"succeeded\"}")));
var result = paymentClient.charge(100);
assertThat(result.id()).isEqualTo("ch_123");
verify(postRequestedFor(urlEqualTo("/v1/charges"))
.withHeader("Authorization", containing("Bearer")));
}
}
stubFor() patterns
- urlEqualTo / urlMatching — path matching
- withHeader, withQueryParam — request conditions
- willReturn(aResponse()…) — status, headers, body, delay (.withFixedDelay(ms) for timeout tests)
- inScenario("retry") — sequence stubs for retry/backoff tests
stubFor(get(urlEqualTo("/rates"))
.inScenario("flaky")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("retry"));
stubFor(get(urlEqualTo("/rates"))
.inScenario("flaky")
.whenScenarioStateIs("retry")
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"usd\":1.0}")));
verify() — assert outbound calls
WireMock records every request. Use verify() to assert your client sent the right payload, headers, and call count—complementing Mockito's verify() on internal beans.
Use @RestClientTest + MockRestServiceServer for pure client-unit tests; reserve WireMock for multi-bean flows where Spring wiring, retries, and circuit breakers matter.
Test configuration & profiles
Keep test-only beans and properties out of production code. Profiles, YAML overrides, and small @TestConfiguration classes give you a clean test harness without polluting the main application.
@TestConfiguration
Defines beans available only in tests—import explicitly with @Import or nest as a static inner class. Not picked up by component scanning in production.
@TestConfiguration
static class TestBeans {
@Bean Clock fixedClock() {
return Clock.fixed(Instant.parse("2026-01-15T10:00:00Z"), ZoneOffset.UTC);
}
}
@SpringBootTest
@Import(OrderServiceTest.TestBeans.class)
class OrderServiceTest {
@Autowired Clock clock;
// deterministic "now" for expiry logic
}
@ActiveProfiles("test")
Activates the test profile so Spring loads application-test.yml and beans annotated @Profile("test").
@SpringBootTest
@ActiveProfiles("test")
class NotificationServiceTest { }
application-test.yml
spring:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL
kafka:
bootstrap-servers: ${spring.embedded.kafka.brokers:localhost:9092}
logging:
level:
org.hibernate.SQL: debug
com.acme: debug
acme:
payments:
base-url: http://localhost:9561 # WireMock port
Never point application-test.yml at production databases—even accidentally via shared env vars. Use distinct URLs, disable external webhooks, and set spring.main.allow-bean-definition-overriding=true only when you intentionally replace beans in tests.
Context caching — speed at scale
Spring Test Framework caches ApplicationContext instances keyed by the full set of configuration metadata (annotations, profiles, property sources, imported classes). The first test class with a given key pays startup cost; subsequent classes with the same key reuse the warm context.
flowchart LR T1["Test class A\n@WebMvcTest + mocks"] --> C1["Context cache key #1"] T2["Test class B\n@WebMvcTest + mocks"] --> C1 T3["Test class C\n@MockBean PaymentService"] --> C2["Context cache key #2\n(different mock set)"] C1 --> R["Reuse same ApplicationContext"] C2 --> N["New context — cache miss"]
Cache invalidation triggers:
- Different @MockBean / @SpyBean declarations per class
- Different @ActiveProfiles or @TestPropertySource values
- @DirtiesContext on a test method or class
- Custom ContextCustomizer that varies per class
@DirtiesContext — use sparingly
Marks the context dirty after a test so the next test gets a fresh context. Powerful but expensive—each dirty mark can force a full restart for that cache key.
@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)
// Only when a test mutates singleton beans or static state — rare
@SpringBootTest
class CacheEvictionTest {
@Test
@DirtiesContext(methodMode = MethodMode.AFTER_METHOD)
void clearsGlobalCacheManager() {
cacheManager.getCache("products").clear(); // mutates shared singleton
}
}
Slapping @DirtiesContext on every test class is a common cause of 10-minute CI suites. Prefer transactional rollback (@DataJpaTest, @Transactional on integration tests), isolated test data, or immutable beans instead.
Log context cache stats in CI: set logging.level.org.springframework.test.context.cache=DEBUG. You'll see cache hits vs misses and can refactor tests that accidentally bust the cache with unique @MockBean layouts.
Recommended test layout
| Layer | Tool | Speed |
|---|---|---|
| Controller mapping & validation | @WebMvcTest + MockMvc | ⚡ fastest |
| Repository queries | @DataJpaTest + H2 | ⚡ fast |
| JSON contracts | @JsonTest | ⚡ fast |
| Outbound HTTP client | @RestClientTest or WireMock | fast |
| SQL dialect / migrations | Testcontainers + PostgreSQL | moderate |
| Full user journey | @SpringBootTest + Testcontainers | slower — few tests |