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.

junior mid Spring Boot 3.x

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.

Full context smoke 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
@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
@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);
  }
}
⚠ Pitfall

@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

AnnotationLoadsTypical use
@DataRedisTestRedis + StringRedisTemplate / RedisTemplateCache keys, pub/sub serializers (Caching)
@DataMongoTestMongoDB + MongoTemplateDocument repositories, aggregation queries
@JsonTestJackson ObjectMapper onlySerialize/deserialize DTOs without MVC
@RestClientTestRestTemplate / RestClient + MockRestServiceServerOutbound HTTP client code
@JsonTest & @RestClientTest
@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();
  }
}
💡 Pro Tip

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).
🎯 Interview

"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.

MockMvc on full context
@SpringBootTest
@AutoConfigureMockMvc
class FullStackMvcTest {
  @Autowired MockMvc mockMvc;
}

perform() — HTTP verbs

Static imports from MockMvcRequestBuilders and MockMvcResultMatchers keep tests readable.

CRUD with MockMvc
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

MatcherExample
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"))
jsonPath deep assertions
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.

Debug failing test
mockMvc.perform(post("/api/orders").content("{bad json"))
    .andDo(print())                    // shows status, headers, body
    .andExpect(status().isBadRequest());
🌍 Real World

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.

AnnotationBehaviorUse when
@MockBeanReplaces the bean with a Mockito mock (all methods stubbed to defaults)Collaborator not under test—OrderService in a controller test
@SpyBeanWraps the real bean; call real methods unless stubbedPartial mocking—verify one method was called but let others run
@MockBean & @SpyBean
@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"))));
  }
}
⚠ Pitfall

@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.

Next: Testcontainers →

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:

build.gradle
testImplementation "org.springframework.boot:spring-boot-testcontainers"
testImplementation "org.testcontainers:junit-jupiter"
testImplementation "org.testcontainers:postgresql"
testImplementation "org.testcontainers:kafka"

@Testcontainers and @Container

PostgreSQL integration test
@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.

Redis & Kafka containers
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 */ }
}
🔬 Under the Hood

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.

@ServiceConnection — less boilerplate
@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() { /* ... */ }
}
📌 Version Note

@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.

🎯 Interview

"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:

Dependency
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.

@WireMockTest with Spring
@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
Simulate failure then success
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.

💡 Pro Tip

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
@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").

Class-level profile
@SpringBootTest
@ActiveProfiles("test")
class NotificationServiceTest { }

application-test.yml

src/test/resources/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
🌍 Real World

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
@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
  }
}
⚠ Pitfall

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.

💡 Pro Tip

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

LayerToolSpeed
Controller mapping & validation@WebMvcTest + MockMvc⚡ fastest
Repository queries@DataJpaTest + H2⚡ fast
JSON contracts@JsonTest⚡ fast
Outbound HTTP client@RestClientTest or WireMockfast
SQL dialect / migrationsTestcontainers + PostgreSQLmoderate
Full user journey@SpringBootTest + Testcontainersslower — few tests