Testing Like
a Professional

Most developers write tests to make CI green. Professional engineers write tests to encode system behaviour, catch regressions, document contracts, and enable fearless refactoring. There is a world of difference between a test suite that gives you confidence and one that gives you false confidence. This section teaches how real engineering teams test production systems — not how to pass a tutorial.

Testing Philosophy: What Are Tests For?

A test suite has one job: give you confidence that the system works correctly. The moment your tests stop giving you confidence — because they're too slow, too brittle, too coupled to implementation — they become friction rather than value.

The Test Confidence Pyramid
E2E / Contract
Few tests · Slowest · Highest confidence
Full system behaviour, API contracts
Integration Tests
Medium count · Medium speed · Real DB/Redis
API layer, repository layer, cross-component
Unit Tests
Many tests · Fastest · Business logic focus
Services, domain logic, calculations, validators
Unit
Run in milliseconds
No I/O, no Spring context
80% of your test count
Integration
Seconds per test
Real containers (Testcontainers)
15% of your test count
E2E/Contract
Minutes per suite
Full application stack
5% of your test count
The Over-Mocking Trap

The most common testing mistake: mocking everything so that tests pass even when the real system is completely broken. If you mock the repository, mock the cache, mock the event bus, and mock the validator — you're testing that your mock configuration is correct, not that your system works. The rule: mock at the boundary of your system (external HTTP calls, email service), use real implementations for things you own.

Unit Testing with JUnit 5 & Mockito

A unit test exercises a single class in complete isolation. It runs in milliseconds, has no external dependencies, and tests business logic — not framework plumbing. The art is knowing what to test and what to skip.

Service Layer Unit Tests

Java
// The class under test
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final PaymentGateway paymentGateway;
    private final ApplicationEventPublisher eventPublisher;

    public Order createOrder(Long userId, Long productId, int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException(productId));

        if (product.getStock() < quantity) {
            throw new InsufficientStockException(productId, quantity);
        }

        Money total = product.getPrice().multiply(quantity);
        PaymentResult payment = paymentGateway.charge(userId, total);

        if (!payment.isSuccessful()) {
            throw new PaymentFailedException(payment.getErrorCode());
        }

        Order order = Order.create(userId, productId, quantity, total);
        Order saved = orderRepository.save(order);
        eventPublisher.publishEvent(new OrderCreatedEvent(saved));
        return saved;
    }
}

// Unit test — no Spring context, runs in <10ms
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock private OrderRepository orderRepository;
    @Mock private ProductRepository productRepository;
    @Mock private PaymentGateway paymentGateway;
    @Mock private ApplicationEventPublisher eventPublisher;

    @InjectMocks private OrderService orderService;

    private Product product;

    @BeforeEach
    void setUp() {
        product = Product.builder()
            .id(1L)
            .name("MacBook Pro")
            .price(Money.of(2999.00, "USD"))
            .stock(10)
            .build();
    }

    @Test
    @DisplayName("should create order when stock is sufficient and payment succeeds")
    void createOrder_success() {
        // Arrange
        given(productRepository.findById(1L)).willReturn(Optional.of(product));
        given(paymentGateway.charge(any(), any()))
            .willReturn(PaymentResult.success("txn-123"));
        given(orderRepository.save(any(Order.class)))
            .willAnswer(inv -> inv.getArgument(0));

        // Act
        Order order = orderService.createOrder(100L, 1L, 2);

        // Assert
        assertThat(order.getUserId()).isEqualTo(100L);
        assertThat(order.getQuantity()).isEqualTo(2);
        assertThat(order.getTotal()).isEqualTo(Money.of(5998.00, "USD"));

        // Verify side effects
        then(orderRepository).should(times(1)).save(any(Order.class));
        then(eventPublisher).should(times(1))
            .publishEvent(any(OrderCreatedEvent.class));
    }

    @Test
    @DisplayName("should throw InsufficientStockException when stock is too low")
    void createOrder_insufficientStock() {
        // Arrange
        Product lowStockProduct = product.toBuilder().stock(1).build();
        given(productRepository.findById(1L))
            .willReturn(Optional.of(lowStockProduct));

        // Act + Assert
        assertThatThrownBy(() -> orderService.createOrder(100L, 1L, 5))
            .isInstanceOf(InsufficientStockException.class)
            .hasMessageContaining("product 1");

        // Verify payment was never charged
        then(paymentGateway).shouldHaveNoInteractions();
        then(orderRepository).shouldHaveNoInteractions();
    }

    @Test
    @DisplayName("should throw PaymentFailedException when payment is declined")
    void createOrder_paymentFailed() {
        given(productRepository.findById(1L)).willReturn(Optional.of(product));
        given(paymentGateway.charge(any(), any()))
            .willReturn(PaymentResult.failure("INSUFFICIENT_FUNDS"));

        assertThatThrownBy(() -> orderService.createOrder(100L, 1L, 1))
            .isInstanceOf(PaymentFailedException.class)
            .hasFieldOrPropertyWithValue("errorCode", "INSUFFICIENT_FUNDS");

        then(orderRepository).shouldHaveNoInteractions();
        then(eventPublisher).shouldHaveNoInteractions();
    }

    @Test
    @DisplayName("should throw ProductNotFoundException for non-existent product")
    void createOrder_productNotFound() {
        given(productRepository.findById(999L)).willReturn(Optional.empty());

        assertThatThrownBy(() -> orderService.createOrder(100L, 999L, 1))
            .isInstanceOf(ProductNotFoundException.class);
    }
}

Testing Edge Cases with Parameterized Tests

Java
@ParameterizedTest(name = "quantity={0} should be {1}")
@CsvSource({
    "0,    false",
    "-1,   false",
    "1,    true",
    "100,  true",
    "101,  false"  // stock is 100
})
void validateQuantity(int quantity, boolean expected) {
    boolean valid = orderService.isQuantityValid(productId, quantity);
    assertThat(valid).isEqualTo(expected);
}

// Test with complex object inputs
@ParameterizedTest
@MethodSource("provideOrderScenarios")
void orderPricing(int quantity, BigDecimal price, BigDecimal expectedTotal) {
    Product p = product.toBuilder()
        .price(Money.of(price, "USD")).build();
    given(productRepository.findById(any())).willReturn(Optional.of(p));
    // ... assert expected total
}

static Stream<Arguments> provideOrderScenarios() {
    return Stream.of(
        Arguments.of(1,  new BigDecimal("10.00"), new BigDecimal("10.00")),
        Arguments.of(3,  new BigDecimal("10.00"), new BigDecimal("30.00")),
        Arguments.of(10, new BigDecimal("99.99"), new BigDecimal("999.90"))
    );
}

// Testing with @Spy — partial mock (real object, some methods overridden)
@Spy
private EmailFormatter emailFormatter = new EmailFormatter();

@Test
void sendWelcomeEmail() {
    // emailFormatter.format() runs real code, but we can verify it was called
    doReturn("<h1>Welcome</h1>").when(emailFormatter).format(any());
    emailService.sendWelcome("user@example.com");
    verify(emailFormatter).format(any(WelcomeEmailTemplate.class));
}

// Testing time-dependent code with fixed clock
@Test
void tokenExpiry() {
    Clock fixedClock = Clock.fixed(
        Instant.parse("2025-01-01T12:00:00Z"), ZoneOffset.UTC);
    JwtService jwtService = new JwtService(secret, fixedClock);

    String token = jwtService.generateToken("userId-1", Duration.ofHours(1));
    // Token expires at 13:00 — test at 12:30 (valid) and 13:30 (expired)
    Clock at1230 = Clock.fixed(
        Instant.parse("2025-01-01T12:30:00Z"), ZoneOffset.UTC);
    assertThat(jwtService.isValid(token, at1230)).isTrue();

    Clock at1330 = Clock.fixed(
        Instant.parse("2025-01-01T13:30:00Z"), ZoneOffset.UTC);
    assertThat(jwtService.isValid(token, at1330)).isFalse();
}
What Deserves a Unit Test?

Unit tests are high-value for: business logic with conditional branches, calculations and transformations, exception handling logic, input validation, domain model behaviour. Unit tests are low-value for: simple getters/setters, one-liner delegation methods, constructors with no logic, Spring configuration classes, and controller mappings (test those with MockMvc instead).

MockMvc: Testing the Web Layer

MockMvc tests your controllers without starting a real HTTP server. It exercises the full Spring MVC pipeline: request mapping, argument resolution, validation, exception handling, and response serialization. This is the right way to test your API contract.

Java
// Slice test — only loads web layer beans, mocks service layer
@WebMvcTest(OrderController.class)
@AutoConfigureMockMvc
class OrderControllerTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;

    @MockBean private OrderService orderService;  // Spring-managed mock
    @MockBean private JwtService jwtService;

    @Test
    @DisplayName("POST /api/v1/orders returns 201 with order on success")
    void createOrder_returns201() throws Exception {
        // Arrange
        CreateOrderRequest request = new CreateOrderRequest(1L, 2);
        Order createdOrder = Order.builder()
            .id(100L).productId(1L).quantity(2)
            .total(Money.of(5998.00, "USD"))
            .status(OrderStatus.PENDING)
            .build();

        given(orderService.createOrder(any(), eq(1L), eq(2)))
            .willReturn(createdOrder);
        given(jwtService.extractUserId(any())).willReturn(42L);

        // Act + Assert
        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", "Bearer test-token")
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(100))
            .andExpect(jsonPath("$.status").value("PENDING"))
            .andExpect(jsonPath("$.total.amount").value(5998.00))
            .andDo(print());  // Print request/response in test output
    }

    @Test
    @DisplayName("POST /api/v1/orders returns 400 when quantity is zero")
    void createOrder_returns400_forZeroQuantity() throws Exception {
        CreateOrderRequest invalidRequest = new CreateOrderRequest(1L, 0);

        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", "Bearer test-token")
                .content(objectMapper.writeValueAsString(invalidRequest)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0].field").value("quantity"))
            .andExpect(jsonPath("$.errors[0].message").exists());

        // Service should never be called with invalid input
        then(orderService).shouldHaveNoInteractions();
    }

    @Test
    @DisplayName("POST /api/v1/orders returns 409 when stock insufficient")
    void createOrder_returns409_onInsufficientStock() throws Exception {
        CreateOrderRequest request = new CreateOrderRequest(1L, 999);
        given(orderService.createOrder(any(), any(), any()))
            .willThrow(new InsufficientStockException(1L, 999));

        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", "Bearer test-token")
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.message").value(containsString("stock")));
    }

    @Test
    @DisplayName("GET /api/v1/orders returns paginated results")
    void getOrders_returnsPaginatedResults() throws Exception {
        Page<Order> page = new PageImpl<>(
            List.of(Order.builder().id(1L).build()),
            PageRequest.of(0, 20),
            1
        );
        given(orderService.getUserOrders(any(), any())).willReturn(page);

        mockMvc.perform(get("/api/v1/orders?page=0&size=20")
                .header("Authorization", "Bearer test-token"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.content").isArray())
            .andExpect(jsonPath("$.content.length()").value(1))
            .andExpect(jsonPath("$.totalElements").value(1))
            .andExpect(jsonPath("$.totalPages").value(1));
    }

    @Test
    @DisplayName("returns 401 when Authorization header is missing")
    @WithAnonymousUser
    void createOrder_returns401_withNoAuth() throws Exception {
        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}"))
            .andExpect(status().isUnauthorized());
    }
}

Testing Security with @WithMockUser

Java
@Test
@WithMockUser(username = "admin@example.com", roles = {"ADMIN"})
void adminEndpoint_allowsAdmin() throws Exception {
    mockMvc.perform(delete("/api/v1/products/1"))
        .andExpect(status().isNoContent());
}

@Test
@WithMockUser(username = "user@example.com", roles = {"USER"})
void adminEndpoint_forbidsRegularUser() throws Exception {
    mockMvc.perform(delete("/api/v1/products/1"))
        .andExpect(status().isForbidden());
}

// Custom SecurityMockMvcResultMatchers for JWT-based auth
@Test
void jwtProtectedEndpoint() throws Exception {
    String token = jwtService.generateToken("user-1", Duration.ofHours(1));

    mockMvc.perform(get("/api/v1/profile")
            .header("Authorization", "Bearer " + token))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.userId").value("user-1"));
}

// ResultHandlers — print, log, save to file for documentation
mockMvc.perform(get("/api/v1/products/1"))
    .andDo(print())             // print to stdout
    .andDo(document("get-product",  // Spring REST Docs — generates docs from tests!
        pathParameters(
            parameterWithName("id").description("Product ID")
        ),
        responseFields(
            fieldWithPath("id").description("Product identifier"),
            fieldWithPath("name").description("Product name"),
            fieldWithPath("price.amount").description("Price amount"),
            fieldWithPath("price.currency").description("ISO currency code")
        )
    ))
    .andExpect(status().isOk());

Testcontainers: Real Dependencies in Tests

Testcontainers spins up real Docker containers for your tests. No more H2 in-memory databases that behave differently from PostgreSQL, no more Redis mocks that don't support Lua scripts, no more Kafka stubs that don't test serialization. Tests run against the same technology as production.

Java
// build.gradle
testImplementation 'org.testcontainers:testcontainers:1.19.3'
testImplementation 'org.testcontainers:postgresql:1.19.3'
testImplementation 'org.testcontainers:kafka:1.19.3'
testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
testImplementation 'com.redis:testcontainers-redis:2.0.1'

// Shared container configuration — reused across all tests in the suite
// Containers start once, run all tests, then stop. Huge time saving!
@TestConfiguration
public class TestContainersConfig {

    // Static fields = containers are started ONCE for entire test suite
    @Container
    static final PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test")
            .withReuse(true);  // Reuse across test runs (speeds CI)

    @Container
    static final GenericContainer<?> redis =
        new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379)
            .withReuse(true);

    @Container
    static final KafkaContainer kafka =
        new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"))
            .withReuse(true);

    // Override Spring properties with container connection details
    @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);
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port",
            () -> redis.getMappedPort(6379));
        registry.add("spring.kafka.bootstrap-servers",
            kafka::getBootstrapServers);
    }
}

// Base class — inherit in all integration tests
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TestContainersConfig.class)
@Transactional  // Rollback after each test
public abstract class BaseIntegrationTest {
    @Autowired protected MockMvc mockMvc;
    @Autowired protected ObjectMapper objectMapper;
    @Autowired protected JdbcTemplate jdbcTemplate;
}

// Concrete integration test — inherits containers
class OrderIntegrationTest extends BaseIntegrationTest {

    @Autowired private OrderRepository orderRepository;

    @Test
    @Sql("/test-data/products.sql")  // Load test fixtures
    void createOrder_persistsToDatabase() throws Exception {
        CreateOrderRequest request = new CreateOrderRequest(1L, 2);

        mockMvc.perform(post("/api/v1/orders")
                .header("Authorization", bearerToken("user-1"))
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").isNumber());

        // Verify it actually hit the database
        List<Order> orders = orderRepository.findByUserId("user-1");
        assertThat(orders).hasSize(1);
        assertThat(orders.get(0).getProductId()).isEqualTo(1L);
    }
}

// Test with real Redis
class CacheIntegrationTest extends BaseIntegrationTest {

    @Autowired private ProductCacheService cacheService;
    @Autowired private StringRedisTemplate redisTemplate;

    @Test
    void getProduct_cachesMissResultInRedis() {
        // First call — cache miss, loads from DB
        Product product = cacheService.getProduct(1L);
        assertThat(product).isNotNull();

        // Verify Redis has the entry
        assertThat(redisTemplate.hasKey("product:1")).isTrue();

        // Second call — must come from cache (verify DB not hit)
        Product cached = cacheService.getProduct(1L);
        assertThat(cached.getId()).isEqualTo(product.getId());
    }

    @Test
    void updateProduct_evictsCachedEntry() {
        cacheService.getProduct(1L);          // Populate cache
        assertThat(redisTemplate.hasKey("product:1")).isTrue();

        cacheService.updateProduct(1L, new ProductUpdateRequest("New Name"));

        // Cache entry must be gone after update
        assertThat(redisTemplate.hasKey("product:1")).isFalse();
    }
}
Pro Tip: Container Reuse with .withReuse(true)

By default, Testcontainers stops and removes containers after each test class. With .withReuse(true) (requires testcontainers.reuse.enable=true in ~/.testcontainers.properties), the same container instance is reused across multiple test runs in the same machine session. Your integration test suite drops from 45 seconds to 8 seconds — the 37 seconds you were spending on container startup is reclaimed. Always clear test data between tests with @Transactional rollback or explicit SQL cleanup, since the container state persists.

Repository Layer Testing

Repository tests verify that your queries work correctly against a real database. Custom JPQL, native SQL, and derived query methods all need testing — they fail at runtime in ways you'll never catch with mocks.

Java
// @DataJpaTest loads only JPA layer — much faster than @SpringBootTest
// Replaces datasource with the Testcontainers one via @AutoConfigureTestDatabase
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(TestContainersConfig.class)
class OrderRepositoryTest {

    @Autowired private OrderRepository orderRepository;
    @Autowired private TestEntityManager entityManager;

    @Test
    void findByUserIdAndStatus_returnsMatchingOrders() {
        // Arrange — persist test data directly via EntityManager
        Order o1 = entityManager.persist(Order.builder()
            .userId(1L).status(OrderStatus.PENDING).build());
        Order o2 = entityManager.persist(Order.builder()
            .userId(1L).status(OrderStatus.COMPLETED).build());
        Order o3 = entityManager.persist(Order.builder()
            .userId(2L).status(OrderStatus.PENDING).build());
        entityManager.flush(); // Flush to DB before querying

        // Act
        List<Order> result = orderRepository
            .findByUserIdAndStatus(1L, OrderStatus.PENDING);

        // Assert
        assertThat(result).hasSize(1);
        assertThat(result.get(0).getId()).isEqualTo(o1.getId());
    }

    @Test
    void findTopSpenders_returnsOrderedByTotal() {
        // Test native SQL query — pagination must work
        Page<SpenderProjection> topSpenders = orderRepository
            .findTopSpenders(PageRequest.of(0, 5));

        assertThat(topSpenders.getContent()).isSortedAccordingTo(
            Comparator.comparingDouble(SpenderProjection::getTotalSpent)
                .reversed()
        );
    }

    @Test
    void countByCreatedAtBetween_returnsCorrectCount() {
        // Test time-range queries — very common bug source
        LocalDateTime start = LocalDateTime.of(2025, 1, 1, 0, 0);
        LocalDateTime end = LocalDateTime.of(2025, 1, 31, 23, 59);

        entityManager.persist(Order.builder()
            .createdAt(LocalDateTime.of(2025, 1, 15, 10, 0)).build());
        entityManager.persist(Order.builder()
            .createdAt(LocalDateTime.of(2025, 2, 1, 10, 0)).build()); // Outside range
        entityManager.flush();

        long count = orderRepository.countByCreatedAtBetween(start, end);
        assertThat(count).isEqualTo(1);
    }
}

Testing Kafka Producers & Consumers

Kafka integration tests are tricky because they involve async behaviour — you produce a message and need to wait for the consumer to process it. Testcontainers with an embedded Kafka makes this possible without mocking the broker.

Java
@SpringBootTest
@EmbeddedKafka(partitions = 1,
               brokerProperties = {"listeners=PLAINTEXT://localhost:9092"},
               topics = {"order-events", "order-events-dlq"})
class OrderEventTest {

    @Autowired private KafkaTemplate<String, OrderEvent> kafkaTemplate;
    @Autowired private OrderEventConsumer consumer;

    // Latch to synchronize async processing in tests
    private static final CountDownLatch latch = new CountDownLatch(1);
    private static OrderEvent receivedEvent;

    @Test
    void orderCreatedEvent_isConsumedSuccessfully() throws Exception {
        OrderEvent event = new OrderEvent("ORDER_CREATED", 42L, 100L);

        kafkaTemplate.send("order-events", event).get(); // Wait for ack

        // Wait up to 10s for consumer to process
        boolean processed = latch.await(10, TimeUnit.SECONDS);

        assertThat(processed).as("Event was not consumed within 10 seconds").isTrue();
        assertThat(receivedEvent.getOrderId()).isEqualTo(42L);
    }

    // Mock consumer listener that signals the latch
    @Component
    static class TestConsumerHelper {
        @KafkaListener(topics = "order-events")
        void onEvent(OrderEvent event) {
            receivedEvent = event;
            latch.countDown();
        }
    }
}

// Testing consumer error handling — messages sent to DLQ
@Test
void malformedMessage_sentToDeadLetterTopic() throws Exception {
    // Use raw template to send invalid JSON
    KafkaTemplate<String, String> rawTemplate = ...;
    rawTemplate.send("order-events", "not-valid-json").get();

    CountDownLatch dlqLatch = new CountDownLatch(1);

    // Listen to DLQ
    consumer = new Consumer<ConsumerRecord<?, ?>>() {
        public void accept(ConsumerRecord<?, ?> record) {
            dlqLatch.countDown();
        }
    };

    boolean dlqReceived = dlqLatch.await(15, TimeUnit.SECONDS);
    assertThat(dlqReceived).isTrue();
}

Contract Testing with Spring Cloud Contract

In a microservices architecture, Service A calls Service B. How do you know their API contract hasn't broken? Integration tests catch this but are slow, expensive, and require both services to be running. Contract testing is a faster, cheaper alternative that can run in CI without inter-service dependencies.

Consumer-Driven Contract Testing Flow
Consumer (Order Service)
1. Write contract (Groovy DSL)
2. Generate stub from contract
3. Run tests against stub
✓ No real Product Service needed
4. Publish contract to repo
Contract
shared repo
Provider (Product Service)
1. Pull contract from repo
2. Auto-generate provider tests
3. Run against real implementation
✓ Ensures contract is upheld
4. Fails CI if contract breaks
Groovy (Contract DSL)
// File: src/test/resources/contracts/products/get_product.groovy
// Written by the CONSUMER team (Order Service)
Contract.make {
    description "should return product by id"

    request {
        method GET()
        url "/api/v1/products/1"
        headers {
            contentType(applicationJson())
        }
    }

    response {
        status OK()
        headers {
            contentType(applicationJson())
        }
        body([
            id: 1,
            name: "MacBook Pro",
            price: [
                amount: $(consumer(1499.00), producer(1499.00)),
                currency: "USD"
            ],
            stock: $(consumer(anyPositiveInt()), producer(10))
        ])
    }
}
Java
// Consumer side: test using generated stub
@SpringBootTest
@AutoConfigureStubRunner(
    ids = "com.example:product-service:+:stubs:8090",
    stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class OrderServiceContractTest {

    @Autowired private OrderService orderService;

    @Test
    void createOrder_callsProductServiceContract() {
        // This calls the stub at localhost:8090
        // The stub was generated from the contract above
        Order order = orderService.createOrder(1L, 1L, 2);
        assertThat(order.getTotal().getAmount())
            .isEqualByComparingTo(new BigDecimal("2998.00")); // 2 × 1499.00
    }
}

// Provider side: auto-generated test (provider doesn't write this!)
// Spring Cloud Contract generates this from the contract file
@SpringBootTest
public class ProductContractVerificationTest extends HttpVerifier {

    @LocalServerPort
    private int port;

    @BeforeEach
    void setUp() {
        RestAssured.baseURI = "http://localhost:" + port;
    }

    // Generated test for get_product.groovy contract:
    @Test
    public void validate_get_product() throws Exception {
        Response response = given()
            .header("Content-Type", "application/json")
            .get("/api/v1/products/1");

        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.jsonPath().getString("name"))
            .isEqualTo("MacBook Pro");
        // All fields from contract are verified automatically
    }
}

Spring Boot Test Slices

Loading the full Spring context for every test is slow. Spring Boot's test slices load only the beans relevant to a specific layer, making tests 5–10x faster while still exercising real Spring infrastructure.

Spring Boot Test Slice Annotations
@WebMvcTest
Loads: Controllers, Filters, ControllerAdvice
Excludes: Services, Repositories, DB
Use: Test API contract, validation, auth
@DataJpaTest
Loads: JPA repositories, EntityManager
Excludes: Web layer, Services
Use: Query correctness, entity mapping
@JsonTest
Loads: Jackson ObjectMapper only
Excludes: Everything else
Use: JSON serialization/deserialization
@DataRedisTest
Loads: Redis repositories, RedisTemplate
Excludes: Web, JPA, Services
Use: Redis operations, cache logic
@RestClientTest
Loads: RestTemplate/WebClient + MockServer
Excludes: Web layer, DB
Use: HTTP client behaviour, retry logic
@SpringBootTest
Loads: Full application context
Excludes: Nothing
Use: Full integration tests, E2E tests
Java
// @JsonTest — test serialization without any Spring context overhead
@JsonTest
class ProductDtoJsonTest {

    @Autowired private JacksonTester<ProductDto> json;

    @Test
    void serialize_includesAllRequiredFields() throws Exception {
        ProductDto dto = ProductDto.builder()
            .id(1L).name("MacBook Pro")
            .price(new MoneyDto(new BigDecimal("1499.00"), "USD"))
            .build();

        JsonContent<ProductDto> written = json.write(dto);

        assertThat(written).hasJsonPathNumberValue("$.id", 1);
        assertThat(written).hasJsonPathStringValue("$.name", "MacBook Pro");
        assertThat(written).hasJsonPathNumberValue("$.price.amount", 1499.00);
        assertThat(written).hasJsonPathStringValue("$.price.currency", "USD");
        // Verify no internal fields leak into the API response
        assertThat(written).doesNotHaveJsonPath("$.internalCost");
        assertThat(written).doesNotHaveJsonPath("$.supplierId");
    }

    @Test
    void deserialize_parsesDateFieldCorrectly() throws Exception {
        String json = """
            {
              "id": 1,
              "createdAt": "2025-01-15T10:30:00Z"
            }
            """;

        ProductDto dto = this.json.parse(json).getObject();

        assertThat(dto.getCreatedAt())
            .isEqualTo(Instant.parse("2025-01-15T10:30:00Z"));
    }
}

// @RestClientTest — test HTTP clients and retry behaviour
@RestClientTest(ProductServiceClient.class)
class ProductServiceClientTest {

    @Autowired private ProductServiceClient client;
    @Autowired private MockRestServiceServer server;

    @Test
    void getProduct_parsesResponseCorrectly() {
        server.expect(requestTo("/api/v1/products/1"))
            .andExpect(method(HttpMethod.GET))
            .andRespond(withSuccess(
                "{\"id\":1,\"name\":\"MacBook Pro\"}",
                MediaType.APPLICATION_JSON));

        Product product = client.getProduct(1L);

        assertThat(product.getName()).isEqualTo("MacBook Pro");
        server.verify();
    }

    @Test
    void getProduct_retries_onServerError() {
        // First two calls return 500, third returns 200
        server.expect(ExpectedCount.times(3), requestTo("/api/v1/products/1"))
            .andRespond(
                withServerError(),
                withServerError(),
                withSuccess("{\"id\":1}", MediaType.APPLICATION_JSON));

        Product product = client.getProduct(1L); // Should retry twice
        assertThat(product).isNotNull();
        server.verify();
    }
}

Test Data Management

Poorly managed test data is the primary cause of flaky tests. Tests that depend on each other's data, tests that leave state behind, tests that assume specific IDs exist — all of these cause false failures that erode trust in the test suite.

Java
// Pattern 1: @Sql annotations for test data
@Test
@Sql(scripts = "/sql/products.sql",
     executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/sql/cleanup.sql",
     executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void test_withSqlFixtures() { ... }

// Pattern 2: Test Data Builder (preferred for complex objects)
// Fluent builder that creates valid defaults, override only what matters
public class ProductTestDataBuilder {
    private Long id = null;               // null = auto-generated
    private String name = "Test Product";
    private BigDecimal price = new BigDecimal("9.99");
    private String currency = "USD";
    private int stock = 100;
    private ProductStatus status = ProductStatus.ACTIVE;

    public static ProductTestDataBuilder aProduct() {
        return new ProductTestDataBuilder();
    }

    public ProductTestDataBuilder withName(String name) {
        this.name = name; return this;
    }

    public ProductTestDataBuilder withPrice(double price) {
        this.price = new BigDecimal(price); return this;
    }

    public ProductTestDataBuilder outOfStock() {
        this.stock = 0; return this;
    }

    public ProductTestDataBuilder discontinued() {
        this.status = ProductStatus.DISCONTINUED; return this;
    }

    public Product build() {
        return Product.builder()
            .id(id).name(name)
            .price(Money.of(price, currency))
            .stock(stock).status(status).build();
    }

    public Product persist(ProductRepository repo) {
        return repo.save(build());
    }
}

// Tests read cleanly — intent is obvious
@Test
void discontinuedProduct_cannotBeOrdered() {
    Product product = aProduct().discontinued().persist(productRepository);
    assertThatThrownBy(() -> orderService.createOrder(1L, product.getId(), 1))
        .isInstanceOf(ProductNotAvailableException.class);
}

// Pattern 3: @Transactional rollback (cleanest for JPA tests)
@SpringBootTest
@Transactional  // Everything in this test class rolls back automatically
class OrderServiceIntegrationTest {
    // Each test gets a clean database state — no teardown needed
}

// Pattern 4: Custom TestEntityManager wrapper
@Component
public class TestFixtures {
    @Autowired private UserRepository userRepo;
    @Autowired private ProductRepository productRepo;

    public User createUser(String email) {
        return userRepo.save(User.builder()
            .email(email).role(Role.USER).build());
    }

    public Product createProduct(String name, double price) {
        return productRepo.save(Product.builder()
            .name(name).price(Money.of(price, "USD")).stock(100).build());
    }
}
Never Use Random Data in Tests

Tests using UUID.randomUUID() or Faker.randomEmail() for every field are non-deterministic — they pass today and fail tomorrow with a different value that triggers a bug. Use fixed, meaningful test data. If you need multiple unique values, use a counter (user1@test.com, user2@test.com) or a fixed set of enum values. Randomness in test data hides assumptions about valid value ranges.

Security Testing

Security tests verify that authorization is correctly applied. These tests should be non-negotiable for any API endpoint. A missing authorization test is an authorization bug waiting to be exploited.

Java
// Comprehensive security test matrix
@WebMvcTest(AdminController.class)
class AdminControllerSecurityTest {

    @Autowired private MockMvc mockMvc;
    @MockBean private AdminService adminService;

    // Test every endpoint with every relevant role
    @ParameterizedTest
    @MethodSource("adminEndpointAccessMatrix")
    void adminEndpoint_accessControl(
            String method, String url,
            String role, int expectedStatus) throws Exception {
        MockHttpServletRequestBuilder request = switch (method) {
            case "GET" -> get(url);
            case "POST" -> post(url).contentType(APPLICATION_JSON).content("{}");
            case "DELETE" -> delete(url);
            default -> throw new IllegalArgumentException(method);
        };

        if (role != null) {
            request = request.with(user("test").roles(role));
        }

        mockMvc.perform(request)
            .andExpect(status().is(expectedStatus));
    }

    static Stream<Arguments> adminEndpointAccessMatrix() {
        return Stream.of(
            // (method, url, role, expectedStatus)
            Arguments.of("GET",    "/admin/users",    "ADMIN", 200),
            Arguments.of("GET",    "/admin/users",    "USER",  403),
            Arguments.of("GET",    "/admin/users",    null,    401),  // no auth
            Arguments.of("DELETE", "/admin/users/1",  "ADMIN", 204),
            Arguments.of("DELETE", "/admin/users/1",  "USER",  403),
            Arguments.of("DELETE", "/admin/users/1",  null,    401),
            Arguments.of("POST",   "/admin/users",    "ADMIN", 201),
            Arguments.of("POST",   "/admin/users",    "USER",  403)
        );
    }
}

// Test for IDOR (Insecure Direct Object Reference)
@Test
@WithMockUser(username = "user-1")
void getOrder_forbids_accessingOtherUsersOrder() throws Exception {
    // Order belongs to user-2, not user-1
    given(orderService.getOrder(anyLong()))
        .willReturn(Order.builder().userId(2L).build());

    mockMvc.perform(get("/api/v1/orders/999"))
        .andExpect(status().isForbidden());
}

// Test for SQL injection in query params
@Test
void searchProducts_sanitizesQueryParam() throws Exception {
    // SQL injection attempt in search param
    mockMvc.perform(get("/api/v1/products/search")
            .param("q", "'; DROP TABLE products; --"))
        .andExpect(status().isOk())  // Should not throw/crash
        .andExpect(jsonPath("$.content").isArray());
        // Never returns 500 — that would confirm DB error from injection
}

// Test rate limiting
@Test
void rateLimitedEndpoint_returns429_afterThreshold() throws Exception {
    for (int i = 0; i < 100; i++) {
        mockMvc.perform(post("/api/v1/auth/login")
            .contentType(APPLICATION_JSON)
            .content("{\"email\":\"brute@force.com\",\"password\":\"wrong\"}"));
    }

    mockMvc.perform(post("/api/v1/auth/login")
            .contentType(APPLICATION_JSON)
            .content("{\"email\":\"brute@force.com\",\"password\":\"wrong\"}"))
        .andExpect(status().isTooManyRequests());
}

Mutation Testing: Do Your Tests Actually Test Anything?

Code coverage tells you which lines were executed. It does not tell you whether your tests would catch a bug. Mutation testing does. It introduces small defects (mutations) into your code and checks whether your tests catch them. If they don't, your tests are insufficient.

XML (Maven — PIT Mutation)
<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>1.15.0</version>
  <dependencies>
    <dependency>
      <groupId>org.pitest</groupId>
      <artifactId>pitest-junit5-plugin</artifactId>
      <version>1.2.1</version>
    </dependency>
  </dependencies>
  <configuration>
    <targetClasses>
      <param>com.example.service.*</param>
      <param>com.example.domain.*</param>
    </targetClasses>
    <targetTests>
      <param>com.example.*Test</param>
    </targetTests>
    <mutationThreshold>80</mutationThreshold>  <!-- fail if < 80% killed -->
    <outputFormats>HTML</outputFormats>
  </configuration>
</plugin>

<!-- Run with: mvn test-compile org.pitest:pitest-maven:mutationCoverage -->
Java
// PIT introduces mutations like:
// - Change > to >= (boundary condition)
// - Change + to - (arithmetic)
// - Remove method call (side effect removal)
// - Negate boolean (conditional negation)
// - Remove null check (null guard removal)

// Example: this code has 100% line coverage but survives mutation
public boolean canShip(Order order) {
    return order.getStatus() == OrderStatus.READY;  // Mutant: != instead of ==
}

@Test
void canShip_returnsTrue_forReadyOrder() {
    Order order = Order.builder().status(OrderStatus.READY).build();
    assertThat(orderService.canShip(order)).isTrue();
    // This test passes with BOTH == and != mutations!
}

// Mutation-killing tests cover BOTH the positive AND negative cases:
@Test
void canShip_returnsTrue_forReadyOrder() {
    assertThat(orderService.canShip(
        Order.builder().status(OrderStatus.READY).build())).isTrue();
}

@Test
void canShip_returnsFalse_forPendingOrder() {
    assertThat(orderService.canShip(
        Order.builder().status(OrderStatus.PENDING).build())).isFalse();
    // NOW the != mutation is caught — this test would FAIL with !=
}

Flaky Tests: Finding & Fixing Them

A flaky test is one that sometimes passes and sometimes fails without any code change. Flaky tests are worse than no tests — they erode trust so that engineers start ignoring failures, including real ones.

Common Flaky Test Root Causes
Timing & Async
Thread.sleep(500) is not enough on a slow CI
Fix: Use Awaitility with polling + timeout
await().atMost(10, SECONDS).until(...)
Shared State
Static variables, database, Redis not cleaned
Fix: @Transactional rollback, @AfterEach cleanup
Never share mutable state between tests
Test Order Dependency
Test B depends on Test A running first
Fix: Each test must set up its own data
Use @TestMethodOrder(Random.class) to detect
Port Conflicts
Parallel tests bind the same port
Fix: Use RANDOM_PORT in @SpringBootTest
Testcontainers auto-assigns random ports
Clock Dependency
Test passes at 11pm but fails after midnight
Fix: Inject Clock, use fixed Clock in tests
Resource Exhaustion
DB connection pool exhausted on parallel runs
Fix: Increase pool size for tests, or limit parallelism
Java
// Bad: Fragile sleep-based async test
@Test
void processOrderAsync_badTest() throws Exception {
    orderService.processAsync(orderId);
    Thread.sleep(500);  // Fails on slow CI, wastes 500ms on fast CI
    assertThat(orderRepository.findById(orderId).get().getStatus())
        .isEqualTo(OrderStatus.PROCESSED);
}

// Good: Awaitility — polls with backoff until condition met or timeout
@Test
void processOrderAsync_goodTest() {
    orderService.processAsync(orderId);

    await()
        .pollInterval(Duration.ofMillis(100))
        .atMost(Duration.ofSeconds(10))
        .untilAsserted(() -> {
            Order order = orderRepository.findById(orderId).orElseThrow();
            assertThat(order.getStatus()).isEqualTo(OrderStatus.PROCESSED);
        });
}

// Detecting test order issues: randomize test execution order
@TestMethodOrder(MethodOrderer.Random.class)
class OrderServiceTest {
    // If any test fails when order is randomized,
    // you have a test order dependency — fix it!
}

// Detecting flaky tests: @RepeatedTest to stress-test a suspicious test
@RepeatedTest(50)
void suspiciousTest() {
    // If this fails even once in 50 runs, it's flaky
    assertThat(someAsyncOperation()).isNotNull();
}

Testing Anti-Patterns to Avoid

1. Testing Implementation, Not Behaviour

Verifying that repository.findById() was called once tightly couples your test to the implementation. If you refactor to use a cache first, the test breaks — even though behaviour is identical. Test outputs and side effects, not internal method calls.

2. One Assertion Per Test (taken too far)

"One assertion per test" is advice to keep tests focused, not a rule to have exactly one assertThat(). Splitting a logical scenario into 10 micro-tests creates 10x the setup code and makes it impossible to see what a test is verifying. Group related assertions for the same scenario.

3. Ignoring the Unhappy Path

90% of production bugs live in error handling code. Every method that can throw an exception needs a test for the thrown exception. Every null return needs a test. Every boundary value needs a test. Most tutorial tests only cover the happy path — don't make the same mistake.

4. @SpringBootTest for Everything

Loading the full Spring context for a unit test adds 5–15 seconds of overhead. A simple service test with @SpringBootTest takes 12 seconds; with @ExtendWith(MockitoExtension.class) it takes 20ms. Over 500 tests, that's 90 minutes vs 10 seconds of total test time.

5. Mocking the Class Under Test

If you find yourself mocking the very class you're testing, stop. You've inverted the test. @Spy on the class under test is occasionally valid for partial mocking, but usually signals that the class has too many responsibilities and should be split.

6. Skipping Tests "Just This Once" in CI

Once you introduce @Disabled or ./mvnw test -Dskip.tests to unblock a release, it becomes a habit. Disabled tests are technical debt with interest — they accumulate, nobody fixes them, and eventually the test suite is useless. Fix the test or delete it, never disable it.

Interview Preparation

Testing is a senior-level differentiator. These questions are asked in system design and engineering culture rounds to determine whether you write production-grade code or tutorial-grade code.

Q: What's the difference between @SpringBootTest, @WebMvcTest, and @DataJpaTest?
@SpringBootTest loads the full application context — all beans, all configurations. Use it for true integration tests where you need the full stack. @WebMvcTest loads only the web layer: controllers, filters, ControllerAdvice, and security configuration. Services and repositories must be mocked with @MockBean. It's 10–50x faster than @SpringBootTest and the right choice for testing API contracts, request validation, and HTTP response shapes. @DataJpaTest loads only the JPA layer: repositories, EntityManager, and the datasource. Everything else must be mocked. It's the right choice for testing custom queries, entity mappings, and repository methods. The key insight: use the narrowest annotation that tests what you need. Broader annotations are slower and test more things simultaneously, making failures harder to diagnose.
Q: Why use Testcontainers instead of H2 for integration tests?
H2 is convenient but it's not PostgreSQL. Differences that matter: (1) H2's DDL syntax differs — native PostgreSQL types like jsonb, uuid, and array types either don't exist or behave differently; (2) H2's query planner differs — slow queries on PostgreSQL can be fast on H2, hiding performance issues; (3) H2 doesn't support PostgreSQL-specific features: partial indexes, CTEs, ON CONFLICT, row-level locking, and Postgres-specific functions; (4) Flyway migrations targeting PostgreSQL often don't run correctly on H2. Testcontainers solves all of this by running the actual PostgreSQL Docker image. Tests are slower to start but faster to trust. The "test pyramid" cost is real: integration tests should be fewer but more trustworthy than unit tests. H2 gives you speed at the cost of confidence — the wrong tradeoff for integration tests.
Q: How do you test asynchronous behaviour in Spring Boot?
Three approaches, in increasing reliability: (1) CountDownLatch — the test thread waits on a latch that the async consumer signals. Simple but fragile on slow CI since you must choose a fixed timeout. (2) Awaitility — polls a condition at an interval until it's true or a timeout is reached. Better than fixed sleep because it doesn't waste time if the async operation completes quickly, and is configurable. Best practice: await().atMost(10, SECONDS).untilAsserted(() -> assertThat(...)). (3) @EmbeddedKafka or embedded broker — for messaging tests, use Spring's embedded broker and synchronously wait for consumer acknowledgement. Never use Thread.sleep() for async tests — it's both fragile (fails on slow CI) and slow (wastes time on fast hardware). Also: design your async code to be testable — inject an Executor that can be swapped for a SynchronousTaskExecutor in tests, making async code run synchronously in the test thread.
Q: What is contract testing and when does it replace integration testing?
Contract testing verifies that two services agree on their API contract — the shape of requests and responses. It doesn't replace integration testing but addresses a different problem: in a microservices environment, spinning up all services for integration tests is slow, brittle, and hard to maintain. Contract testing (with Spring Cloud Contract or Pact) allows each service to be tested in isolation: the consumer writes a contract file describing the API calls it makes and the responses it expects; the provider verifies its implementation satisfies those contracts. Contracts are stored in a shared repository (like Pact Broker). When the provider changes its API, it runs consumer contracts against the change and fails CI if any contract is broken — before deployment, before any consumer is affected. The key distinction: integration tests verify that two running services work together correctly end-to-end; contract tests verify that each service's implementation matches the agreed API shape. Use both: contract tests in every PR, full integration tests in staging.
Q: What is mutation testing and how does it reveal weak tests?
Mutation testing (PIT is the Java tool) works by automatically introducing small bugs — mutations — into your production code: flipping a > to >=, changing a + to -, removing a method call, negating a boolean. It then runs your test suite against each mutated version. If your tests catch the bug (fail), the mutation is "killed." If your tests still pass with a bug present, the mutation "survives" — meaning your tests wouldn't catch a real version of that bug. A high mutation score (80%+ killed) indicates meaningful tests. A high line coverage with low mutation score reveals tests that execute code without verifying its correctness — the worst kind of tests because they give false confidence. Mutation testing is expensive (generates hundreds of mutants, runs full test suite for each) so you typically run it on critical business logic modules in CI, not on every package.
Q: How would you test a method that calls an external payment API?
At least three layers: (1) Unit test with mock — mock the PaymentGateway interface, test that your service correctly handles success/failure/timeout responses. Verify error handling, retry logic, and event publishing. Fast, no network. (2) Integration test with WireMock — spin up a WireMock server that simulates the payment provider. Test realistic JSON parsing, HTTP status code handling, and timeout behaviour with realistic payloads. @WireMockTest in Spring Boot makes this easy. (3) Contract test — if you're building the payment client, write consumer contracts. If you're a consumer of an external provider (Stripe, Braintree), download and parse their OpenAPI spec and validate your client against it. Production hygiene: the real payment API should only be called in staging/production environments. Never allow test code to reach production payment endpoints — use environment-based configuration with a payment provider sandbox for manual QA.
Q: A test was passing for 6 months and suddenly started failing. How would you debug it?
Systematic approach: (1) Check if it's consistently failing or intermittent — intermittent means flaky, consistent means something environmental changed. (2) Check what DID change: deployment of a dependency, infrastructure update, data migration, time-based trigger. A test that was passing for "6 months" often breaks because of a date (January 1 edge case, year rollover, leap second). (3) Check for external state: if the test uses a shared database, Redis, or message broker, was data seeded differently? (4) Check for timing: did the CI machine slow down due to increased parallel jobs? (5) Check for version drift: did a transitive dependency update silently change behaviour? (6) Add temporal logging to find the exact first failure in CI history — this often reveals a correlation with a specific deployment. (7) Run the test in isolation with -v logs. Often tests that "suddenly fail" were relying on a global state set by a test that used to run before them, and a reordering exposed the dependency. Fix: make the test self-contained, inject a fixed clock, use @Transactional rollback, and run with @TestMethodOrder(Random.class) to detect order dependencies.
🧪

Section 10 Complete

You now test like a professional engineer — unit tests that kill mutations, MockMvc contract verification, Testcontainers against real databases, Kafka async testing with Awaitility, consumer-driven contract testing, and a systematic approach to eliminating flaky tests from your CI pipeline.