Testing in Spring
@SpringBootTest
Loads the full Spring application context for integration-style testing — as opposed to slice tests (@WebMvcTest, @DataJpaTest) which load only a focused subset of the context. The trade-off is fidelity (closer to production wiring) versus speed (full context startup is slow, especially across a large test suite run repeatedly).
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class OrderIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldCreateOrderAndPersist() {
OrderRequest request = new OrderRequest(1L, List.of(new OrderItemRequest("SKU-1", 2)));
ResponseEntity<OrderResponse> response =
restTemplate.postForEntity("/api/v1/orders", request, OrderResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(orderRepository.findById(response.getBody().id())).isPresent();
}
}
webEnvironment options — what they actually start:
| Value | Behavior |
|---|---|
MOCK (default) |
No real servlet container; uses MockMvc-style request dispatch |
RANDOM_PORT |
Starts a real embedded server (Tomcat/Netty) on a random free port — needed for true end-to-end HTTP-level tests, including filters/security |
DEFINED_PORT |
Real server on the configured port (rarely used — port conflicts in parallel test runs) |
NONE |
No web environment at all — for testing non-web context beans |
Architect-level guidance — test pyramid discipline: @SpringBootTest should be the minority of a test suite, not the default for every test. Loading the full context for a test that only needs to verify a single service method’s logic is wasteful — slice annotations (@WebMvcTest for controller-layer tests with mocked service dependencies, @DataJpaTest for repository-layer tests against an embedded/Testcontainers DB) give faster, more targeted feedback. Reserve @SpringBootTest for genuine end-to-end flows (a handful per feature), not as the universal default — a common anti-pattern in codebases that grew test coverage without deliberate test-layer strategy is a suite where every single test is @SpringBootTest, making the suite slow and brittle as it scales.
// Context caching matters for suite speed: Spring caches contexts across test classes
// with IDENTICAL configuration (same active profiles, same @MockBean set, etc.)
// Varying configuration per test class defeats caching and multiplies startup cost across the suite
@SpringBootTest
@ActiveProfiles("test") // keep consistent across classes wherever possible to maximize context reuse
class OrderServiceTest { }
MockMvc
Simulates HTTP requests against the Spring MVC dispatcher chain without starting a real servlet container — fast, full request-processing pipeline fidelity (filters, interceptors, @ControllerAdvice, JSON serialization) without network overhead.
@WebMvcTest(OrderController.class) // loads ONLY the web layer + this controller, mocks everything else
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturnOrderById() throws Exception {
when(orderService.findById(1L))
.thenReturn(new OrderResponse(1L, "ORD-001", OrderStatus.SHIPPED));
mockMvc.perform(get("/api/v1/orders/{id}", 1L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderNumber").value("ORD-001"))
.andExpect(jsonPath("$.status").value("SHIPPED"));
}
@Test
void shouldReturn400OnInvalidRequest() throws Exception {
OrderRequest invalidRequest = new OrderRequest(null, List.of()); // fails @NotNull/@NotEmpty
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
// this also verifies the @ControllerAdvice exception handler wiring end-to-end
}
@Test
void shouldRequireAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/orders/1"))
.andExpect(status().isUnauthorized());
// confirms the security filter chain is actually applied, not bypassed in the test slice
}
}
@WebMvcTest loads: DispatcherServlet, @ControllerAdvice beans, HandlerInterceptors, configured Filters (including security filter chain if spring-boot-starter-security is on the classpath), JSON converters — but not @Service/@Repository beans, which must be supplied as @MockBean. This is precisely what makes it fast (no DB, no full context) while still validating real HTTP-layer behavior — status codes, JSON shape, validation error responses, security gates — that a pure unit test on the controller class instantiated manually would miss entirely.
Architect-level point — what MockMvc tests are for: they validate the contract at the HTTP boundary (status codes, response shape, header behavior, security enforcement, validation error format) — not business logic, which belongs in unit tests against the service layer directly. A common test-suite smell is duplicating business-logic assertions inside @WebMvcTest cases that are already covered by service-layer unit tests — the controller test’s job is to confirm the wiring is correct, not to re-verify logic that’s already tested elsewhere.
Mockito
Mocking framework for isolating the unit under test from its dependencies — replacing real collaborators with controllable stand-ins that return programmed responses and record interactions for verification.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private InventoryClient inventoryClient;
@InjectMocks
private OrderService orderService; // Mockito injects the @Mock fields into this instance's constructor
@Test
void shouldThrowWhenInventoryUnavailable() {
when(inventoryClient.getStock("SKU-1"))
.thenReturn(new InventoryResponse("SKU-1", 0));
assertThatThrownBy(() -> orderService.placeOrder(buildRequest("SKU-1", 5)))
.isInstanceOf(InsufficientStockException.class)
.hasMessageContaining("SKU-1");
verify(orderRepository, never()).save(any()); // confirms NO save occurred on failure path
}
@Test
void shouldCallInventoryReserveExactlyOnce() {
when(inventoryClient.getStock("SKU-1")).thenReturn(new InventoryResponse("SKU-1", 100));
when(orderRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
orderService.placeOrder(buildRequest("SKU-1", 5));
verify(inventoryClient, times(1)).reserve(any(ReservationRequest.class));
verify(inventoryClient, never()).getStock(argThat(sku -> !sku.equals("SKU-1")));
}
@Captor
ArgumentCaptor<Order> orderCaptor;
@Test
void shouldSaveOrderWithCorrectStatus() {
when(inventoryClient.getStock(any())).thenReturn(new InventoryResponse("SKU-1", 100));
orderService.placeOrder(buildRequest("SKU-1", 5));
verify(orderRepository).save(orderCaptor.capture());
assertThat(orderCaptor.getValue().getStatus()).isEqualTo(OrderStatus.PENDING);
}
}
@Mock vs @Spy — a precise distinction worth having ready:
| Behavior | |
|---|---|
@Mock |
Fully fake object — every method returns a default (null/0/empty) unless explicitly stubbed |
@Spy |
Wraps a real object — real methods execute unless explicitly stubbed with when(spy.method())... or doReturn(...).when(spy).method() |
@Spy
private List<String> realList = new ArrayList<>();
// realList.add("x") actually adds to a real ArrayList — only stub the specific calls you need to override
when().thenReturn() vs doReturn().when() — when the latter is mandatory: for void methods, or when spying on a real object where calling the real method during stub setup would have side effects, doReturn()/doThrow()/doNothing() must be used instead of when(), since when(spy.voidMethod()) would actually execute the real void method as part of setting up the stub.
Architect-level point — unit tests should mock collaborators, not the system under test’s own internals: a common anti-pattern is over-mocking — stubbing so much of the surrounding behavior that the test verifies the mock’s configuration rather than the actual unit’s logic. If a test requires extensive mock setup to exercise a few lines of real logic, it’s often a signal the method/class has too many dependencies (an SRP smell) rather than purely a testing problem — worth raising in code review as a design issue, not just a test-writing one.
Testcontainers
Spins up real, ephemeral Docker containers (actual Postgres, actual Kafka, actual Redis — not embedded/in-memory fakes) scoped to the test lifecycle, giving integration tests genuine fidelity to production infrastructure behavior that H2-in-Postgres-compatibility-mode or embedded Kafka can’t fully replicate (dialect-specific SQL behavior, actual Kafka rebalancing semantics, real Redis eviction behavior).
@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("orders_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistAndRetrieveOrder() {
Order saved = orderRepository.save(new Order("ORD-100", OrderStatus.PENDING));
Optional<Order> found = orderRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getOrderNumber()).isEqualTo("ORD-100");
}
}
Multi-container setups for full integration coverage (Kafka + Postgres together, a common real-world test shape):
@Testcontainers
class OrderEventFlowIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
}
Container reuse across test classes — a real cost optimization, not just a nice-to-have: by default, @Container static fields scoped per-class start/stop a fresh container per test class, adding meaningful startup overhead (several seconds per container) across a large suite. Testcontainers supports container reuse (.withReuse(true) + a global testcontainers.properties opt-in) to keep one container alive across the whole test run — worth knowing as a deliberate suite-speed lever, since “our integration tests are slow” is a very common complaint that’s frequently solvable through reuse configuration rather than reducing test coverage.
Architect-level positioning — where Testcontainers fits relative to other test types in the pyramid:
| Test type | Speed | Fidelity | When |
|---|---|---|---|
| Pure unit test (Mockito) | Fastest | Lowest (everything mocked) | Business logic, branching, edge cases — the bulk of the suite |
@WebMvcTest/@DataJpaTest slice |
Fast | Medium | Layer-boundary contracts |
@SpringBootTest + Testcontainers |
Slowest | Highest (real DB/broker behavior) | A focused set of true end-to-end flows, dialect-specific query behavior, schema migration validation |
The specific class of bug Testcontainers catches that mocks/embedded fakes can’t: dialect-specific SQL behavior (a JPQL query that works against H2 but fails against real Postgres due to a function name difference or case-sensitivity quirk), actual constraint violation behavior, and real connection-pool exhaustion under concurrent load — these only surface against the real engine. This is the strongest argument for using Testcontainers over H2-for-everything in any project where the production database is not H2, which describes the overwhelming majority of real systems — a frequently-cited justification worth having ready when asked “why not just use an in-memory database for all integration tests.”