Skip to content

04 · Testing at Scale

Level 2 covered JUnit 5 fundamentals for testing pure logic. Production services need three more layers: mocking collaborators so a unit test isolates one class, integration tests that exercise real Spring wiring and HTTP, and tests that run against a real database in a disposable container.

Mockito basics

Mockito creates fake implementations of interfaces/classes so a unit test can isolate the class under test from its dependencies.

<!-- pom.xml -- included transitively by spring-boot-starter-test -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
// OrderServiceTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;   // fake -- no real database involved

    @InjectMocks
    private OrderService orderService;         // real class, with the mock injected in

    @Test
    void cancelOrder_marksOrderCancelled() {
        Order existing = new Order(1L, "PENDING", 99.0);
        when(orderRepository.findById(1L)).thenReturn(Optional.of(existing));
        when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));

        Order result = orderService.cancel(1L);

        assertEquals("CANCELLED", result.status());
        verify(orderRepository).save(argThat(o -> o.status().equals("CANCELLED")));
    }

    @Test
    void cancelOrder_throwsWhenNotFound() {
        when(orderRepository.findById(99L)).thenReturn(Optional.empty());

        assertThrows(OrderNotFoundException.class, () -> orderService.cancel(99L));
        verify(orderRepository, never()).save(any());   // never saved because it never got that far
    }
}

when(...).thenReturn(...) stubs behavior; verify(...) asserts a method was actually called (and how many times, with never()/times(n)). Mockito never touches a real database — this test runs in milliseconds.

Spring Boot integration tests with MockMvc

Unit tests isolate one class. Integration tests boot the real Spring context and exercise HTTP-layer behavior — routing, validation, serialization — without a running server process.

// OrderControllerIT.java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerIT {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void getOrder_returnsJson() throws Exception {
        mockMvc.perform(get("/orders/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.status").value("PENDING"));
    }

    @Test
    void createOrder_validatesInput() throws Exception {
        String badPayload = """
            {"customerId": null, "total": -5}
            """;

        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(badPayload))
            .andExpect(status().isBadRequest());
    }
}

@SpringBootTest starts the full application context; @AutoConfigureMockMvc wires a MockMvc that dispatches requests through the real controller stack without opening a network socket — faster than a full end-to-end HTTP test, but still exercising real request mapping and JSON (de)serialization.

Testcontainers — a real database in tests

Mocking the repository is fine for service-layer logic, but at some point you want to know your JPA queries actually work against a real database engine. Testcontainers starts a real, disposable Postgres instance in Docker just for the test run.

<!-- pom.xml -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.19.7</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.19.7</version>
    <scope>test</scope>
</dependency>
// OrderRepositoryTestcontainersIT.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Testcontainers
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTestcontainersIT {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("orders_test")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void datasourceProperties(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 savesAndReadsBackFromRealPostgres() {
        Order saved = orderRepository.save(new Order(null, "PENDING", 150.0));

        Order found = orderRepository.findById(saved.id()).orElseThrow();

        assertEquals("PENDING", found.status());
    }
}

Testcontainers spins the container up before the test class runs and tears it down after — no shared, hand-maintained test database, and no drift between what tests run against and what production runs on (real Postgres, not an in-memory H2 stand-in that behaves subtly differently).

Test type Speed What it verifies Tool
Unit test with mocks Milliseconds One class's logic in isolation Mockito
Spring integration test ~1 second Controller routing, validation, wiring @SpringBootTest + MockMvc
Testcontainers test Seconds (container startup) Real SQL against a real database engine Testcontainers

How It Actually Works

Test containers (Testcontainers) work by talking to the local Docker daemon's API to spin up a real, ephemeral container (Postgres, Kafka, etc.) before your test class runs, exposing its randomly-assigned host port back to the JVM test process through the Docker API's port mapping — your JDBC URL/connection string is rewritten at runtime to point at that mapped port, so the test genuinely talks to real database wire-protocol behavior, not a mock, and the container is torn down via a JVM shutdown hook the library registers automatically.

Mockito's mock() builds actual subclasses at runtime via ByteBuddy (bytecode generation), intercepting every method call through a generated proxy that consults a MockHandler — when you verify(mock).someMethod(), Mockito is inspecting an internal invocation log the proxy recorded, not doing anything reflective at verification time; the interception happens once, at call time, via real generated bytecode, not dynamic reflection per call.

Test parallelization (JUnit 5's parallel execution) runs test classes on a ForkJoinPool-backed thread pool; because JUnit 5 also creates a new test-instance-per-method by default, parallel test methods within a class don't share instance state — the actual concurrency hazard is almost always shared static state or a shared external resource (a test database, a static cache) that multiple parallel tests mutate without isolation.

Exercise

Write a Mockito-based unit test for a PaymentService.refund(Long orderId) method that mocks a PaymentGatewayClient dependency, asserting the gateway's refund method is called exactly once with the correct amount. Then write a MockMvc integration test for POST /orders/{id}/refund asserting it returns 200 OK and a JSON body with "status": "REFUNDED".