Testing in Spring

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