Skip to content
Published on

Java Testing Complete Guide: JUnit 5, Mockito & WireMock in Practice

Authors

Java Testing Complete Guide: JUnit 5, Mockito & WireMock in Practice

Testing is the foundation of software quality. Well-written tests let you refactor with confidence and catch bugs early. This guide gives you a complete mastery of Java's core testing tools with hands-on examples.


1. JUnit 5 Complete Guide

1.1 JUnit 5 Architecture

JUnit 5 is composed of three sub-projects:

  • JUnit Platform: Test framework execution foundation (Launcher API)
  • JUnit Jupiter: New programming model and extension model (Jupiter API)
  • JUnit Vintage: Backward compatibility for JUnit 3/4 tests
<!-- build.gradle.kts -->
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    testImplementation("org.mockito:mockito-junit-jupiter:5.11.0")
    testImplementation("com.github.tomakehurst:wiremock-jre8:3.0.1")
    testImplementation("org.testcontainers:junit-jupiter:1.19.7")
    testImplementation("org.assertj:assertj-core:3.25.3")
}

tasks.test {
    useJUnitPlatform()
}

1.2 Core Annotations

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

class CalculatorTest {

    private Calculator calculator;

    @BeforeAll
    static void initAll() {
        // Runs once for the entire class (must be static)
        System.out.println("Test class setup");
    }

    @BeforeEach
    void setUp() {
        // Runs before each test method
        calculator = new Calculator();
    }

    @AfterEach
    void tearDown() {
        // Runs after each test method
        calculator = null;
    }

    @AfterAll
    static void tearDownAll() {
        // Runs once after all tests in the class (must be static)
        System.out.println("Test class cleanup");
    }

    @Test
    @DisplayName("The sum of two numbers should be calculated correctly")
    void add_shouldReturnCorrectSum() {
        // given
        int a = 3, b = 5;

        // when
        int result = calculator.add(a, b);

        // then
        assertEquals(8, result, "3 + 5 should equal 8");
    }

    @Test
    @Disabled("Feature not yet implemented")
    void notImplementedYet() {
        // This test is skipped
    }

    @Test
    @Tag("slow")
    void slowTest() {
        // Tag for grouping: ./gradlew test -Dtests.groups=slow
    }
}

1.3 @Nested for Structured Tests

@DisplayName("Order Service Tests")
class OrderServiceTest {

    @Nested
    @DisplayName("When creating an order")
    class WhenCreatingOrder {

        @Test
        @DisplayName("Should succeed when stock is sufficient")
        void shouldSucceedWhenStockIsSufficient() { /* ... */ }

        @Test
        @DisplayName("Should throw when stock is insufficient")
        void shouldThrowWhenStockIsInsufficient() { /* ... */ }
    }

    @Nested
    @DisplayName("When cancelling an order")
    class WhenCancellingOrder {

        @Test
        @DisplayName("Should allow cancellation before shipping")
        void shouldAllowCancellationBeforeShipping() { /* ... */ }

        @Test
        @DisplayName("Should not allow cancellation after shipping")
        void shouldNotAllowCancellationAfterShipping() { /* ... */ }
    }
}

1.4 Parameterized Tests

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;

class ParameterizedTests {

    // @ValueSource: single-type value list
    @ParameterizedTest
    @ValueSource(strings = {"", " ", "\t", "\n"})
    @DisplayName("Blank strings should be detected as blank")
    void blankStringsShouldBeBlank(String input) {
        assertTrue(input.isBlank());
    }

    @ParameterizedTest
    @ValueSource(ints = {1, 3, 5, 7, 9})
    void shouldBeOdd(int number) {
        assertEquals(1, number % 2);
    }

    // @CsvSource: multi-column data
    @ParameterizedTest
    @CsvSource({
        "apple,  5",
        "banana, 6",
        "cherry, 6"
    })
    @DisplayName("Fruit name length should match")
    void fruitLengthShouldMatch(String fruit, int expectedLength) {
        assertEquals(expectedLength, fruit.length());
    }

    // @CsvFileSource: read from CSV file
    @ParameterizedTest
    @CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
    void fromCsvFile(String input, int expected) { /* ... */ }

    // @MethodSource: provide arguments from a method
    @ParameterizedTest
    @MethodSource("provideStringsForIsBlank")
    void isBlank_ShouldReturnExpected(String input, boolean expected) {
        assertEquals(expected, input == null || input.isBlank());
    }

    static Stream<Arguments> provideStringsForIsBlank() {
        return Stream.of(
            Arguments.of(null, true),
            Arguments.of("", true),
            Arguments.of("  ", true),
            Arguments.of("not blank", false)
        );
    }

    // @EnumSource: test with enum values
    @ParameterizedTest
    @EnumSource(value = DayOfWeek.class, names = {"SATURDAY", "SUNDAY"})
    void weekendsAreNotWorkdays(DayOfWeek day) {
        assertFalse(isWorkday(day));
    }
}

1.5 Assertions In Depth

class AssertionsTest {

    @Test
    void groupAssertions() {
        // assertAll: run all assertions, then report all failures
        assertAll("person",
            () -> assertEquals("Alice", person.getName()),
            () -> assertEquals(30, person.getAge()),
            () -> assertEquals("alice@example.com", person.getEmail())
        );
    }

    @Test
    void exceptionTesting() {
        // assertThrows: verify exception type and message
        Exception exception = assertThrows(
            ArithmeticException.class,
            () -> calculator.divide(10, 0)
        );
        assertEquals("Division by zero", exception.getMessage());
    }

    @Test
    void timeoutTesting() {
        // assertTimeout: verify execution time
        assertTimeout(Duration.ofSeconds(1), () -> {
            Thread.sleep(500); // Must complete within 1 second
        });

        // assertTimeoutPreemptively: abort immediately on timeout
        assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
            someBlockingOperation();
        });
    }

    @Test
    void assumptionsTest() {
        // If condition is false, test is skipped (not failed)
        assumeTrue("CI".equals(System.getenv("ENV")),
            "Only runs in CI environment");
        // Code below only runs in CI
        heavyIntegrationTest();
    }
}

1.6 @TestFactory (Dynamic Tests)

@TestFactory
Collection<DynamicTest> dynamicTests() {
    return List.of(
        DynamicTest.dynamicTest("1 + 1 = 2", () -> assertEquals(2, 1 + 1)),
        DynamicTest.dynamicTest("2 + 3 = 5", () -> assertEquals(5, 2 + 3))
    );
}

@TestFactory
Stream<DynamicTest> dynamicTestsFromStream() {
    return Stream.of("apple", "banana", "cherry")
        .map(fruit -> DynamicTest.dynamicTest(
            fruit + " is a fruit",
            () -> assertTrue(isFruit(fruit))
        ));
}

2. Mockito Complete Guide

2.1 Basic Setup

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private EmailService emailService;

    @InjectMocks
    private UserService userService; // @Mock fields are auto-injected

    @Spy
    private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    // @Spy: real object but selected methods can be stubbed

    @Captor
    private ArgumentCaptor<User> userCaptor;
    // ArgumentCaptor: captures arguments passed to method calls
}

2.2 Stubbing Methods

@Test
void stubbing_examples() {
    // Basic stub: return a specific value
    when(userRepository.findById("user1"))
        .thenReturn(Optional.of(new User("user1", "Alice")));

    // Empty or null result
    when(userRepository.findById("nonexistent"))
        .thenReturn(Optional.empty());

    // Throw an exception
    when(userRepository.findById("error"))
        .thenThrow(new DatabaseException("Connection refused"));

    // Consecutive returns (in order)
    when(userRepository.findAll())
        .thenReturn(List.of(user1))
        .thenReturn(List.of(user1, user2))
        .thenReturn(Collections.emptyList());

    // thenAnswer: dynamic return based on arguments
    when(userRepository.findByName(anyString()))
        .thenAnswer(invocation -> {
            String name = invocation.getArgument(0);
            return Optional.of(new User(name.toLowerCase(), name));
        });

    // doReturn: useful for void methods and spies
    doReturn(Optional.of(mockUser)).when(userRepository).findById("spy-test");

    // doThrow: throw on void method
    doThrow(new MailException("SMTP error")).when(emailService).sendWelcomeEmail(any());

    // doNothing: make void method do nothing
    doNothing().when(emailService).sendNotification(anyString());

    // doCallRealMethod: call real method on spy
    doCallRealMethod().when(passwordEncoder).encode(anyString());
}

2.3 Verifying Interactions

@Test
void verify_examples() {
    // Basic: was it called exactly once?
    verify(userRepository).findById("user1");
    verify(userRepository, times(1)).findById("user1");

    // Number of invocations
    verify(emailService, times(3)).sendEmail(anyString());
    verify(userRepository, never()).delete(any());
    verify(emailService, atLeast(2)).sendEmail(anyString());
    verify(emailService, atMost(5)).sendEmail(anyString());

    // Order of invocations
    InOrder inOrder = inOrder(userRepository, emailService);
    inOrder.verify(userRepository).save(any(User.class));
    inOrder.verify(emailService).sendWelcomeEmail(anyString());

    // No interactions beyond the verified ones
    verifyNoMoreInteractions(emailService);
    verifyNoInteractions(auditService);
}

2.4 ArgumentCaptor

@Test
void argumentCaptor_example() {
    // given
    String name = "Alice";
    String email = "alice@example.com";

    // when
    userService.registerUser(name, email);

    // then - capture the saved User object
    verify(userRepository).save(userCaptor.capture());
    User savedUser = userCaptor.getValue();

    assertAll("Verify saved user",
        () -> assertEquals(name, savedUser.getName()),
        () -> assertEquals(email.toLowerCase(), savedUser.getEmail()),
        () -> assertNotNull(savedUser.getCreatedAt()),
        () -> assertEquals(UserStatus.PENDING, savedUser.getStatus())
    );

    // All captured values across multiple calls
    verify(emailService, times(3)).sendEmail(emailCaptor.capture());
    List<String> sentEmails = emailCaptor.getAllValues();
    assertThat(sentEmails).containsExactly(
        "admin@example.com", "alice@example.com", "support@example.com"
    );
}

2.5 ArgumentMatchers

@Test
void matchers_examples() {
    // any(): any type including null
    when(repo.findAll(any())).thenReturn(list);

    // any(SpecificClass.class): specific type
    when(repo.findByExample(any(User.class))).thenReturn(list);

    // eq(): exact value match
    when(repo.findById(eq("specific-id"))).thenReturn(Optional.of(user));

    // anyString(), anyInt(), anyList(), anyMap(), etc.
    when(service.process(anyString(), anyInt())).thenReturn(result);

    // argThat(): custom condition
    when(repo.save(argThat(u -> u.getName().startsWith("A")))).thenReturn(savedUser);

    // Important: when using matchers, ALL arguments must use matchers
    // Bad: verify(service).method("literal", anyInt());
    // Good: verify(service).method(eq("literal"), anyInt());

    // AssertJ-style verification with argThat
    verify(repo).save(argThat(user -> {
        assertThat(user.getName()).isNotBlank();
        assertThat(user.getEmail()).contains("@");
        return true; // must return true
    }));
}

2.6 Strict Stubbings

// MockitoExtension uses strict stubbing by default
// UnnecessaryStubbingException is thrown for stubs that are never called
@ExtendWith(MockitoExtension.class)
class StrictStubbingTest {

    @Test
    void unnecessaryStubbingCausesFailure() {
        // If this stub is not used in the test, the test fails
        when(userRepository.findById("unused")).thenReturn(Optional.of(user));
        // If "unused" is never queried in the test, it will fail
    }

    // Lenient mode: allow unused stubs (useful in shared fixtures)
    @Test
    void lenientStubbing() {
        lenient().when(userRepository.findById("maybe-unused"))
            .thenReturn(Optional.of(user));
        // No exception even if not used
    }
}

3. Implementing Stub Patterns

3.1 In-Memory Repository Stub

// Manual stub based on an interface
public class InMemoryUserRepository implements UserRepository {
    private final Map<String, User> store = new HashMap<>();
    private long idSequence = 1;

    @Override
    public User save(User user) {
        if (user.getId() == null) {
            user = user.withId(String.valueOf(idSequence++));
        }
        store.put(user.getId(), user);
        return user;
    }

    @Override
    public Optional<User> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public List<User> findAll() {
        return new ArrayList<>(store.values());
    }

    @Override
    public void deleteById(String id) {
        store.remove(id);
    }

    @Override
    public boolean existsByEmail(String email) {
        return store.values().stream()
            .anyMatch(u -> email.equals(u.getEmail()));
    }

    // Test helper methods
    public void clear() { store.clear(); }
    public int count() { return store.size(); }
}

// Time Stub using Clock.fixed()
public class OrderServiceTest {

    private final Clock fixedClock = Clock.fixed(
        Instant.parse("2026-03-17T10:00:00Z"),
        ZoneId.of("Asia/Seoul")
    );

    @Test
    void orderShouldHaveCreationTime() {
        OrderService service = new OrderService(orderRepo, fixedClock);
        Order order = service.createOrder(userId, items);

        LocalDateTime expected = LocalDateTime.of(2026, 3, 17, 19, 0, 0); // KST (+9)
        assertEquals(expected, order.getCreatedAt());
    }
}

4. HTTP Mocking with WireMock

4.1 WireMock Setup

import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;

// Option 1: Annotation-based (simple)
@WireMockTest(httpPort = 8089)
class SimpleWireMockTest {

    @Test
    void testWithWireMock(WireMockRuntimeInfo wmRuntimeInfo) {
        stubFor(get("/api/users/1")
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"id\": \"1\", \"name\": \"Alice\"}")));

        String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
        // Use WebClient or RestTemplate to call baseUrl + "/api/users/1"
    }
}

// Option 2: Extension-based (more control)
@ExtendWith(WireMockExtension.class)
class AdvancedWireMockTest {

    @RegisterExtension
    static WireMockExtension wm = WireMockExtension.newInstance()
        .options(wireMockConfig()
            .dynamicPort()
            .dynamicHttpsPort()
            .usingFilesUnderClasspath("wiremock"))
        .build();

    @Test
    void advancedTest() {
        wm.stubFor(get(urlPathEqualTo("/api/users"))
            .withQueryParam("page", equalTo("1"))
            .withHeader("Authorization", matching("Bearer .+"))
            .willReturn(aResponse()
                .withStatus(200)
                .withBodyFile("users-response.json"))); // from src/test/resources/wiremock/__files/
    }
}

4.2 Request Matching and Response Configuration

@Test
void requestMatching_examples() {
    // URL matching
    stubFor(get(urlEqualTo("/exact/path")));            // exact match
    stubFor(get(urlPathEqualTo("/path")));              // ignores query params
    stubFor(get(urlPathMatching("/api/users/[0-9]+"))); // regex

    // Request body matching (POST/PUT)
    stubFor(post(urlPathEqualTo("/api/users"))
        .withRequestBody(equalToJson("""
            {"name": "Alice", "email": "alice@example.com"}
            """, true, true)) // ignoreArrayOrder, ignoreExtraElements
        .withRequestBody(matchingJsonPath("$.email", containing("@")))
        .willReturn(aResponse().withStatus(201)));

    // Fixed delay simulation
    stubFor(get("/slow-endpoint")
        .willReturn(aResponse()
            .withStatus(200)
            .withFixedDelay(2000)     // 2-second fixed delay
            .withBody("Slow response")));

    // Random delay
    stubFor(get("/random-delay")
        .willReturn(aResponse()
            .withStatus(200)
            .withUniformRandomDelay(100, 500))); // 100-500ms random

    // Network fault simulation
    stubFor(get("/network-error")
        .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));

    // Headers in response
    stubFor(get("/with-headers")
        .willReturn(aResponse()
            .withStatus(200)
            .withHeader("Cache-Control", "max-age=3600")
            .withBody("response body")));
}

4.3 Scenarios (Stateful Mocks)

@Test
void statefulMock_scenario() {
    // Scenario: retry pattern test
    // 1st call: 503 error
    // 2nd call: 200 success

    stubFor(get("/flaky-endpoint")
        .inScenario("Retry Scenario")
        .whenScenarioStateIs(STARTED)
        .willReturn(aResponse().withStatus(503))
        .willSetStateTo("First call failed"));

    stubFor(get("/flaky-endpoint")
        .inScenario("Retry Scenario")
        .whenScenarioStateIs("First call failed")
        .willReturn(aResponse()
            .withStatus(200)
            .withBody("Success on retry")));

    // Test a client with retry logic
    String result = retryableClient.get("/flaky-endpoint");
    assertEquals("Success on retry", result);

    // Verify both calls were made
    verify(exactly(2), getRequestedFor(urlEqualTo("/flaky-endpoint")));
}

4.4 Complete WebClient Test Example

@WireMockTest
class UserApiClientTest {

    private UserApiClient client;

    @BeforeEach
    void setUp(WireMockRuntimeInfo wmRuntimeInfo) {
        WebClient webClient = WebClient.builder()
            .baseUrl(wmRuntimeInfo.getHttpBaseUrl())
            .build();
        client = new UserApiClient(webClient);
    }

    @Test
    void getUser_shouldReturnUser_whenFound() {
        // given
        stubFor(get(urlPathEqualTo("/api/users/alice"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {
                        "id": "alice",
                        "name": "Alice",
                        "email": "alice@example.com"
                    }
                    """)));

        // when
        User user = client.getUser("alice").block();

        // then
        assertNotNull(user);
        assertEquals("alice", user.getId());
        assertEquals("Alice", user.getName());

        // Verify WireMock received the correct request
        verify(getRequestedFor(urlPathEqualTo("/api/users/alice"))
            .withHeader("Accept", equalTo("application/json")));
    }

    @Test
    void getUser_shouldThrow_whenNotFound() {
        // given
        stubFor(get(urlPathEqualTo("/api/users/nonexistent"))
            .willReturn(aResponse().withStatus(404)));

        // when / then
        assertThrows(UserNotFoundException.class,
            () -> client.getUser("nonexistent").block());
    }
}

5. Spring Boot Test

5.1 Test Slice Comparison

// @SpringBootTest: loads the full application context (integration tests)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class FullIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void fullFlowTest() {
        ResponseEntity<UserResponse> response = restTemplate.postForEntity(
            "/api/users",
            new CreateUserRequest("Alice", "alice@example.com", 30),
            UserResponse.class
        );
        assertEquals(HttpStatus.CREATED, response.getStatusCode());
    }
}

// @WebMvcTest: loads only the Web layer (Controller, Filter, Interceptor)
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean  // Registers a mock in the Spring Context
    private UserService userService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("POST /api/users should return 201")
    void createUser_shouldReturn201() throws Exception {
        // given
        CreateUserRequest request = new CreateUserRequest("Alice", "alice@example.com", 30);
        UserResponse response = new UserResponse("user-1", "Alice", "alice@example.com",
            LocalDateTime.now());

        when(userService.createUser(any())).thenReturn(response);

        // when / then
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value("user-1"))
            .andExpect(jsonPath("$.name").value("Alice"))
            .andExpect(jsonPath("$.email").value("alice@example.com"))
            .andDo(print());
    }

    @Test
    void createUser_shouldReturn400_whenInvalid() throws Exception {
        CreateUserRequest invalidRequest = new CreateUserRequest("", "not-an-email", -1);

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalidRequest)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors").isArray());
    }
}

// @DataJpaTest: JPA components only (Repository, Entity)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    @Transactional
    void findByEmail_shouldReturnUser_whenExists() {
        // given - persist test data directly
        User user = entityManager.persist(new User(null, "Alice", "alice@example.com"));
        entityManager.flush();

        // when
        Optional<User> found = userRepository.findByEmail("alice@example.com");

        // then
        assertTrue(found.isPresent());
        assertEquals("Alice", found.get().getName());
    }
}

5.2 @MockBean vs @SpyBean

@SpringBootTest
class MockBeanVsSpyBeanTest {

    @MockBean
    private PaymentGateway paymentGateway;
    // Completely replaces the bean with a mock (all methods return defaults)

    @SpyBean
    private NotificationService notificationService;
    // Wraps the real bean with a spy (real methods called, some can be stubbed)

    @Test
    void paymentWithSpyNotification() {
        // PaymentGateway is a full mock
        when(paymentGateway.charge(any(), any())).thenReturn(PaymentResult.success("txn-123"));

        // NotificationService uses real behavior except sendSms
        doNothing().when(notificationService).sendSms(anyString(), anyString());

        orderService.placeOrder(userId, cartId);

        verify(paymentGateway).charge(eq(userId), any(Money.class));
        verify(notificationService).sendEmail(eq(userId), anyString());
        verify(notificationService).sendSms(eq(userId), anyString());
    }
}

5.3 Managing Test Data with @Sql

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    @Sql("/test-data/insert-orders.sql")
    @Sql(scripts = "/test-data/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    void findActiveOrders_shouldReturnCorrectCount() {
        List<Order> activeOrders = orderRepository.findByStatus(OrderStatus.ACTIVE);
        assertEquals(3, activeOrders.size());
    }
}

6. TestContainers

6.1 Integration Tests with Real Databases

@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {

    @Container
    @ServiceConnection  // Spring Boot 3.1+: auto-configures datasource
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @Container
    static RedisContainer redis = new RedisContainer("redis:7")
        .withExposedPorts(6379);

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @Test
    @Transactional
    void createUser_shouldPersistToDatabase() {
        // given
        CreateUserRequest request = new CreateUserRequest("Alice", "alice@example.com", 30);

        // when
        UserResponse response = userService.createUser(request);

        // then - query from actual PostgreSQL
        Optional<User> persisted = userRepository.findById(response.id());
        assertTrue(persisted.isPresent());
        assertEquals("Alice", persisted.get().getName());
    }
}

// Reusable containers (for speed)
public abstract class AbstractIntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16")
            .withReuse(true); // Reuse container between test runs
        POSTGRES.start();
    }

    @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);
    }
}

6.2 Kafka Integration Tests

@SpringBootTest
@Testcontainers
class OrderEventPublisherTest {

    @Container
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
    );

    @DynamicPropertySource
    static void kafkaProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }

    @Autowired
    private OrderService orderService;

    @Test
    void orderCreation_shouldPublishEvent() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<OrderEvent> receivedEvent = new AtomicReference<>();

        // Set up Kafka consumer
        KafkaConsumer<String, OrderEvent> consumer = createTestConsumer();
        consumer.subscribe(List.of("order-events"));

        // when
        orderService.createOrder(userId, items);

        // then - verify Kafka event received
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        OrderEvent event = receivedEvent.get();
        assertNotNull(event);
        assertEquals(OrderEventType.CREATED, event.getType());
    }
}

7. TDD in Practice (Spring Boot)

7.1 Order Service with TDD

// Step 1: Write a failing test (RED)
class OrderServiceTest {

    private final OrderRepository orderRepository = new InMemoryOrderRepository();
    private final ProductRepository productRepository = new InMemoryProductRepository();
    private final OrderService orderService = new OrderService(orderRepository, productRepository);

    @BeforeEach
    void setUp() {
        productRepository.save(new Product("prod-1", "Laptop", 1_500_000, 10));
        productRepository.save(new Product("prod-2", "Mouse", 50_000, 5));
    }

    @Test
    @DisplayName("createOrder: should succeed when stock is sufficient")
    void createOrder_shouldSucceed_whenStockIsSufficient() {
        // given
        List<OrderItem> items = List.of(new OrderItem("prod-1", 2));

        // when
        Order order = orderService.createOrder("user-1", items);

        // then
        assertNotNull(order.getId());
        assertEquals(OrderStatus.PENDING, order.getStatus());
        assertEquals(3_000_000, order.getTotalAmount());
        assertEquals(8, productRepository.findById("prod-1").get().getStock());
    }

    @Test
    @DisplayName("createOrder: should throw when stock is insufficient")
    void createOrder_shouldThrow_whenStockInsufficient() {
        List<OrderItem> items = List.of(new OrderItem("prod-2", 10)); // stock: 5

        assertThrows(InsufficientStockException.class,
            () -> orderService.createOrder("user-1", items));
    }

    @Test
    @DisplayName("cancelOrder: should succeed when status is PENDING")
    void cancelOrder_shouldSucceed_whenPending() {
        Order order = orderService.createOrder("user-1",
            List.of(new OrderItem("prod-1", 1)));

        orderService.cancelOrder(order.getId(), "user-1");

        Order cancelled = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals(OrderStatus.CANCELLED, cancelled.getStatus());
        assertEquals(10, productRepository.findById("prod-1").get().getStock()); // stock restored
    }
}

// Step 2: Minimal implementation (GREEN)
public class OrderService {
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;

    public OrderService(OrderRepository orderRepository, ProductRepository productRepository) {
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
    }

    public Order createOrder(String userId, List<OrderItem> items) {
        // Validate and reserve stock
        for (OrderItem item : items) {
            Product product = productRepository.findById(item.productId())
                .orElseThrow(() -> new ResourceNotFoundException("Product", item.productId()));

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

        // Deduct stock and compute total
        int totalAmount = 0;
        for (OrderItem item : items) {
            Product product = productRepository.findById(item.productId()).orElseThrow();
            productRepository.save(product.decreaseStock(item.quantity()));
            totalAmount += product.getPrice() * item.quantity();
        }

        Order order = new Order(null, userId, items, totalAmount,
            OrderStatus.PENDING, LocalDateTime.now());
        return orderRepository.save(order);
    }

    public void cancelOrder(String orderId, String userId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new ResourceNotFoundException("Order", orderId));

        if (!order.getUserId().equals(userId))
            throw new UnauthorizedException("Not your order");
        if (order.getStatus() != OrderStatus.PENDING)
            throw new InvalidOrderStateException("Cannot cancel order in state: " + order.getStatus());

        // Restore stock
        for (OrderItem item : order.getItems()) {
            Product product = productRepository.findById(item.productId()).orElseThrow();
            productRepository.save(product.increaseStock(item.quantity()));
        }

        orderRepository.save(order.withStatus(OrderStatus.CANCELLED));
    }
}

8. Architecture Testing with ArchUnit

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@AnalyzeClasses(packages = "com.example.app")
class ArchitectureTest {

    @ArchTest
    static final ArchRule layerDependencies = layeredArchitecture()
        .consideringAllDependencies()
        .layer("Controller").definedBy("..controller..")
        .layer("Service").definedBy("..service..")
        .layer("Repository").definedBy("..repository..")
        .layer("Domain").definedBy("..domain..")
        .whereLayer("Controller").mayOnlyBeAccessedByLayers("Controller")
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller", "Service")
        .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
        .whereLayer("Domain").mayOnlyBeAccessedByLayers("Controller", "Service", "Repository");

    @ArchTest
    static final ArchRule servicesShouldNotDependOnControllers =
        noClasses().that().resideInAPackage("..service..")
            .should().dependOnClassesThat().resideInAPackage("..controller..");

    @ArchTest
    static final ArchRule repositoriesShouldBeInterfaces =
        classes().that().resideInAPackage("..repository..")
            .and().haveSimpleNameEndingWith("Repository")
            .should().beInterfaces();

    @ArchTest
    static final ArchRule servicesShouldBeAnnotated =
        classes().that().resideInAPackage("..service.impl..")
            .should().beAnnotatedWith(Service.class);

    @ArchTest
    static final ArchRule noCyclicDependencies =
        slices().matching("com.example.app.(*)..")
            .should().beFreeOfCycles();
}

9. Quizzes

Quiz 1: What condition must @BeforeAll methods satisfy in JUnit 5?

Options:

  • A) Must be public
  • B) Must be static (by default)
  • C) Must return void
  • D) Must have no parameters

Answer: B (must be static)

Explanation: @BeforeAll methods must be static by default because they run once for the entire test class. Exception: with @TestInstance(Lifecycle.PER_CLASS), @BeforeAll can be non-static. The return type must be void. Any access modifier is allowed (public, protected, package-private).

Quiz 2: What is the key difference between @Spy and @Mock in Mockito?

Options:

  • A) @Spy can only be used on interfaces
  • B) @Mock calls real methods while @Spy does not
  • C) @Spy wraps a real object and allows stubbing selected methods
  • D) @Spy and @Mock are functionally identical

Answer: C

Explanation: @Mock creates a complete mock where all methods return default values. @Spy wraps a real instance — real methods are called by default, and only selected methods can be stubbed. @Spy requires a concrete class instance, while @Mock works with both interfaces and classes.

Quiz 3: Why are Scenarios used in WireMock?

Options:

  • A) To improve response speed
  • B) To implement stateful mocks that return different responses based on call order
  • C) To reuse the same stub across multiple tests
  • D) To simulate HTTP authentication

Answer: B

Explanation: WireMock scenarios implement a state machine, allowing the same endpoint to return different responses depending on which invocation it is. Classic use cases include testing retry patterns (503 on first call, 200 on second), shopping cart state changes, and any interaction where order matters.

Quiz 4: What is the main difference between @WebMvcTest and @SpringBootTest?

Options:

  • A) @WebMvcTest uses real HTTP requests and @SpringBootTest uses MockMvc
  • B) @WebMvcTest loads only the Web layer (faster); @SpringBootTest loads the full context
  • C) @SpringBootTest is a unit test and @WebMvcTest is an integration test
  • D) @MockBean cannot be used inside @WebMvcTest

Answer: B

Explanation: @WebMvcTest loads only web-layer components (Controllers, ControllerAdvice, Filters, WebMvcConfigurer) and is fast. @SpringBootTest loads the full Spring context, which is slower but closer to production. Both support @MockBean.

Quiz 5: What is the main advantage of TestContainers?

Options:

  • A) Tests run faster than with H2 in-memory databases
  • B) Reproduces exact vendor-specific behavior, providing high fidelity
  • C) Eliminates the need for any infrastructure in CI/CD pipelines
  • D) Can be used without any Spring Boot configuration

Answer: B

Explanation: TestContainers' primary advantage is running actual Docker containers of PostgreSQL, MySQL, MongoDB, Kafka, etc., reproducing vendor-specific behaviors (SQL syntax, transaction semantics, data types) that H2 in-memory databases cannot replicate. They are slower than H2 but run reliably in CI/CD environments.


Summary

An effective Java testing strategy:

  1. Unit tests: JUnit 5 + Mockito for focused business logic testing
  2. Web layer tests: @WebMvcTest + MockMvc for fast API testing
  3. Integration tests: TestContainers for parity with production infrastructure
  4. HTTP client tests: WireMock to eliminate external API dependencies
  5. Architecture tests: ArchUnit to automate enforcement of design principles

Following the testing pyramid and choosing the right tool for each layer lets you build a test suite that is fast, reliable, and maintainable.