Skip to content

Split View: Java 테스트 완전 정복: JUnit 5, Mockito, WireMock 실전 가이드

|

Java 테스트 완전 정복: JUnit 5, Mockito, WireMock 실전 가이드

Java 테스트 완전 정복: JUnit 5, Mockito, WireMock 실전 가이드

테스트는 소프트웨어 품질의 근간입니다. 잘 작성된 테스트는 리팩토링을 자신있게 할 수 있게 해주고, 버그를 빠르게 발견하게 해줍니다. 이 가이드에서는 Java 테스트의 핵심 도구들을 실전 예시와 함께 완전 정복합니다.


1. JUnit 5 완전 정복

1.1 JUnit 5 아키텍처

JUnit 5는 세 개의 서브프로젝트로 구성됩니다:

  • JUnit Platform: 테스트 프레임워크 실행 기반 (Launcher API)
  • JUnit Jupiter: 새로운 프로그래밍 모델 및 확장 모델 (Jupiter API)
  • JUnit Vintage: JUnit 3/4 테스트 하위 호환성
<!-- build.gradle.kts (Kotlin DSL) -->
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 기본 어노테이션

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

class CalculatorTest {

    private Calculator calculator;

    @BeforeAll
    static void initAll() {
        // 클래스 전체에서 한 번만 실행 (static)
        System.out.println("테스트 클래스 초기화");
    }

    @BeforeEach
    void setUp() {
        // 각 테스트 메서드 전에 실행
        calculator = new Calculator();
    }

    @AfterEach
    void tearDown() {
        // 각 테스트 메서드 후에 실행
        calculator = null;
    }

    @AfterAll
    static void tearDownAll() {
        // 클래스 전체에서 마지막에 한 번만 실행 (static)
        System.out.println("테스트 클래스 종료");
    }

    @Test
    @DisplayName("두 수의 합이 올바르게 계산되어야 한다")
    void add_shouldReturnCorrectSum() {
        // given
        int a = 3, b = 5;

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

        // then
        assertEquals(8, result, "3 + 5 = 8 이어야 합니다");
    }

    @Test
    @Disabled("미구현 기능")
    void notImplementedYet() {
        // 이 테스트는 건너뜀
    }

    @Test
    @Tag("slow")
    void slowTest() {
        // 태그로 그룹화, ./gradlew test -Dtests.groups=slow
    }
}

1.3 @Nested로 테스트 구조화

@DisplayName("주문 서비스 테스트")
class OrderServiceTest {

    @Nested
    @DisplayName("주문 생성 시")
    class WhenCreatingOrder {

        @Test
        @DisplayName("재고가 충분하면 성공해야 한다")
        void shouldSucceedWhenStockIsSufficient() { /* ... */ }

        @Test
        @DisplayName("재고가 부족하면 예외를 던져야 한다")
        void shouldThrowWhenStockIsInsufficient() { /* ... */ }
    }

    @Nested
    @DisplayName("주문 취소 시")
    class WhenCancellingOrder {

        @Test
        @DisplayName("배송 전이면 취소 가능해야 한다")
        void shouldAllowCancellationBeforeShipping() { /* ... */ }

        @Test
        @DisplayName("배송 후에는 취소 불가능해야 한다")
        void shouldNotAllowCancellationAfterShipping() { /* ... */ }
    }
}

1.4 파라미터화 테스트

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

class ParameterizedTests {

    // @ValueSource: 단일 타입 값 목록
    @ParameterizedTest
    @ValueSource(strings = {"", " ", "\t", "\n"})
    @DisplayName("공백 문자열은 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: 여러 컬럼 데이터
    @ParameterizedTest
    @CsvSource({
        "apple,  5",
        "banana, 6",
        "cherry, 6"
    })
    @DisplayName("과일 이름의 길이가 올바르게 반환되어야 한다")
    void fruitLengthShouldMatch(String fruit, int expectedLength) {
        assertEquals(expectedLength, fruit.length());
    }

    // @CsvFileSource: CSV 파일에서 읽기
    @ParameterizedTest
    @CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
    void fromCsvFile(String input, int expected) { /* ... */ }

    // @MethodSource: 메서드에서 인자 제공
    @ParameterizedTest
    @MethodSource("provideStringsForIsBlank")
    void isBlank_ShouldReturnTrue(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: Enum 값으로 테스트
    @ParameterizedTest
    @EnumSource(value = DayOfWeek.class, names = {"SATURDAY", "SUNDAY"})
    void weekendsAreNotWorkdays(DayOfWeek day) {
        assertFalse(isWorkday(day));
    }
}

1.5 Assertions 심화

class AssertionsTest {

    @Test
    void groupAssertions() {
        // assertAll: 모든 검증 수행 후 실패 목록 출력
        assertAll("person",
            () -> assertEquals("Alice", person.getName()),
            () -> assertEquals(30, person.getAge()),
            () -> assertEquals("alice@example.com", person.getEmail())
        );
    }

    @Test
    void exceptionTesting() {
        // assertThrows: 예외 타입 검증
        Exception exception = assertThrows(
            ArithmeticException.class,
            () -> calculator.divide(10, 0)
        );
        assertEquals("Division by zero", exception.getMessage());
    }

    @Test
    void timeoutTesting() {
        // assertTimeout: 시간 초과 검증
        assertTimeout(Duration.ofSeconds(1), () -> {
            // 1초 내에 완료되어야 함
            Thread.sleep(500);
        });

        // assertTimeoutPreemptively: 시간 초과 시 즉시 중단
        assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {
            someBlockingOperation();
        });
    }

    @Test
    void assumptionsTest() {
        // 조건이 false이면 테스트 건너뜀 (실패 아님)
        assumeTrue("CI".equals(System.getenv("ENV")),
            "CI 환경에서만 실행");
        // CI 환경일 때만 아래 코드 실행
        heavyIntegrationTest();
    }
}

1.6 @TestFactory (동적 테스트)

@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 + "는 과일이다",
            () -> assertTrue(isFruit(fruit))
        ));
}

2. Mockito 완전 정복

2.1 기본 설정

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private EmailService emailService;

    @InjectMocks
    private UserService userService; // @Mock 필드들이 자동 주입

    @Spy
    private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    // @Spy: 실제 객체이지만 일부 메서드만 모의 가능

    @Captor
    private ArgumentCaptor<User> userCaptor;
    // ArgumentCaptor: 메서드 호출 시 전달된 인자를 캡처
}

2.2 when().thenReturn() / thenThrow() / thenAnswer()

@Test
void stubbing_examples() {
    // 기본 스텁: 특정 값 반환
    when(userRepository.findById("user1"))
        .thenReturn(Optional.of(new User("user1", "Alice")));

    // null 또는 비어있는 경우
    when(userRepository.findById("nonexistent"))
        .thenReturn(Optional.empty());

    // 예외 발생
    when(userRepository.findById("error"))
        .thenThrow(new DatabaseException("Connection refused"));

    // 연속 반환 (순서대로)
    when(userRepository.findAll())
        .thenReturn(List.of(user1))
        .thenReturn(List.of(user1, user2))
        .thenReturn(Collections.emptyList());

    // thenAnswer: 인자에 따른 동적 반환
    when(userRepository.findByName(anyString()))
        .thenAnswer(invocation -> {
            String name = invocation.getArgument(0);
            return Optional.of(new User(name.toLowerCase(), name));
        });

    // doReturn: void 메서드나 spy에서 유용
    doReturn(Optional.of(mockUser)).when(userRepository).findById("spy-test");

    // doThrow: void 메서드에서 예외 발생
    doThrow(new MailException("SMTP error")).when(emailService).sendWelcomeEmail(any());

    // doNothing: void 메서드를 아무 동작 없이
    doNothing().when(emailService).sendNotification(anyString());

    // doCallRealMethod: spy에서 실제 메서드 호출
    doCallRealMethod().when(passwordEncoder).encode(anyString());
}

2.3 verify()로 인터랙션 검증

@Test
void verify_examples() {
    // 기본 검증: 정확히 1번 호출되었는가
    verify(userRepository).findById("user1");
    verify(userRepository, times(1)).findById("user1");

    // 호출 횟수 검증
    verify(emailService, times(3)).sendEmail(anyString());
    verify(userRepository, never()).delete(any());
    verify(emailService, atLeast(2)).sendEmail(anyString());
    verify(emailService, atMost(5)).sendEmail(anyString());

    // 호출 순서 검증
    InOrder inOrder = inOrder(userRepository, emailService);
    inOrder.verify(userRepository).save(any(User.class));
    inOrder.verify(emailService).sendWelcomeEmail(anyString());

    // 특정 인터랙션 외 다른 호출 없음 검증
    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 - 저장된 User 객체 캡처
    verify(userRepository).save(userCaptor.capture());
    User savedUser = userCaptor.getValue();

    assertAll("저장된 사용자 검증",
        () -> assertEquals(name, savedUser.getName()),
        () -> assertEquals(email.toLowerCase(), savedUser.getEmail()),
        () -> assertNotNull(savedUser.getCreatedAt()),
        () -> assertEquals(UserStatus.PENDING, savedUser.getStatus())
    );

    // 여러 호출의 모든 캡처된 값
    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(): 모든 타입 (null 포함)
    when(repo.findAll(any())).thenReturn(list);

    // any(SpecificClass.class): 특정 타입
    when(repo.findByExample(any(User.class))).thenReturn(list);

    // eq(): 정확히 같은 값
    when(repo.findById(eq("specific-id"))).thenReturn(Optional.of(user));

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

    // argThat(): 커스텀 조건
    when(repo.save(argThat(u -> u.getName().startsWith("A")))).thenReturn(savedUser);

    // 주의: ArgumentMatchers 사용 시 모든 인자를 matcher로 사용해야 함
    // Bad: verify(service).method("literal", anyInt());
    // Good: verify(service).method(eq("literal"), anyInt());

    // assertj와 함께 사용
    verify(repo).save(argThat(user -> {
        assertThat(user.getName()).isNotBlank();
        assertThat(user.getEmail()).contains("@");
        return true; // true 반환 필수
    }));
}

2.6 Strict Stubbings

// MockitoExtension은 기본적으로 strict stubbing 사용
// 불필요한 스텁(실제로 호출되지 않은 스텁)이 있으면 UnnecessaryStubbingException 발생
@ExtendWith(MockitoExtension.class)
class StrictStubbingTest {

    @Test
    void unnecessaryStubbingCausesFailure() {
        // 이 스텁이 테스트 내에서 사용되지 않으면 예외 발생
        when(userRepository.findById("unused")).thenReturn(Optional.of(user));
        // 실제 테스트 코드에서 "unused"로 조회하지 않으면 실패
    }

    // Lenient 모드: 불필요한 스텁 허용 (공유 fixture에서 유용)
    @Test
    void lenientStubbing() {
        lenient().when(userRepository.findById("maybe-unused"))
            .thenReturn(Optional.of(user));
        // 사용되지 않아도 예외 없음
    }
}

3. Stub 패턴 구현

3.1 인메모리 Repository Stub

// 인터페이스 기반 수동 Stub
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()));
    }

    // 테스트 헬퍼 메서드
    public void clear() { store.clear(); }
    public int count() { return store.size(); }
}

// 시간 Stub (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
        assertEquals(expected, order.getCreatedAt());
    }
}

4. WireMock으로 HTTP 모의 서버

4.1 WireMock 설정

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;

// 방법 1: 어노테이션 방식 (간단)
@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\"}")));

        // HTTP 요청 수행
        String baseUrl = wmRuntimeInfo.getHttpBaseUrl();
        // WebClient나 RestTemplate으로 baseUrl + "/api/users/1" 호출
    }
}

// 방법 2: Extension 방식 (더 많은 제어)
@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"))); // src/test/resources/wiremock/__files/
    }
}

4.2 요청 매칭과 응답 설정

@Test
void requestMatching_examples() {
    // URL 매칭
    stubFor(get(urlEqualTo("/exact/path")));           // 정확히 일치
    stubFor(get(urlPathEqualTo("/path")));             // 쿼리 파라미터 무시
    stubFor(get(urlPathMatching("/api/users/[0-9]+")));// 정규식

    // 요청 바디 매칭 (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)));

    // 응답 지연 시뮬레이션
    stubFor(get("/slow-endpoint")
        .willReturn(aResponse()
            .withStatus(200)
            .withFixedDelay(2000)     // 2초 고정 지연
            .withBody("Slow response")));

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

    // 네트워크 오류 시뮬레이션
    stubFor(get("/network-error")
        .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));

    // 헤더 응답
    stubFor(get("/with-headers")
        .willReturn(aResponse()
            .withStatus(200)
            .withHeader("X-Request-Id", "$(randomValue)")
            .withHeader("Cache-Control", "max-age=3600")
            .withBody("response body")));
}

4.3 시나리오 (Stateful Mock)

@Test
void statefulMock_scenario() {
    // 시나리오: 재시도 패턴 테스트
    // 1번째 호출: 503 오류
    // 2번째 호출: 200 성공

    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")));

    // 재시도 로직을 가진 클라이언트 테스트
    String result = retryableClient.get("/flaky-endpoint");
    assertEquals("Success on retry", result);

    // 요청 검증
    verify(exactly(2), getRequestedFor(urlEqualTo("/flaky-endpoint")));
}

4.4 WebClient 테스트 예시

@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(WireMockRuntimeInfo wm) {
        // 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());

        // WireMock 요청 검증
        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 테스트 슬라이스 비교

// @SpringBootTest: 전체 애플리케이션 컨텍스트 로드 (통합 테스트)
@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: Web 레이어만 (Controller, Filter, Interceptor)
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean  // Spring Context에 Mock 등록
    private UserService userService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("사용자 생성 API는 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_whenRequestIsInvalid() throws Exception {
        CreateUserRequest invalidRequest = new CreateUserRequest("", "invalid-email", -1);

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

// @DataJpaTest: JPA 관련 컴포넌트만 (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 - 테스트 데이터 직접 저장
        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;
    // 완전히 모의 객체로 교체 (모든 메서드 기본값 반환)

    @SpyBean
    private NotificationService notificationService;
    // 실제 빈을 spy로 감싸기 (실제 메서드 호출, 일부만 스텁)

    @Test
    void paymentWithSpyNotification() {
        // PaymentGateway는 완전 Mock
        when(paymentGateway.charge(any(), any())).thenReturn(PaymentResult.success("txn-123"));

        // NotificationService는 실제 동작하되 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 @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 실제 DB로 통합 테스트

@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {

    @Container
    @ServiceConnection  // Spring Boot 3.1+: 자동 설정
    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 - 실제 PostgreSQL에서 조회
        Optional<User> persisted = userRepository.findById(response.id());
        assertTrue(persisted.isPresent());
        assertEquals("Alice", persisted.get().getName());
    }
}

// 재사용 가능한 컨테이너 (속도 향상)
public abstract class AbstractIntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16")
            .withReuse(true); // 컨테이너 재사용
        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 통합 테스트

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

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

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

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

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

        // then - Kafka 이벤트 수신 확인
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        OrderEvent event = receivedEvent.get();
        assertNotNull(event);
        assertEquals(OrderEventType.CREATED, event.getType());
    }
}

7. TDD 실전 예시 (Spring Boot)

7.1 주문 서비스 TDD

// Step 1: 실패하는 테스트 작성 (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", "노트북", 1_500_000, 10));
        productRepository.save(new Product("prod-2", "마우스", 50_000, 5));
    }

    @Test
    @DisplayName("주문 생성: 재고가 충분할 때 주문이 생성된다")
    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("주문 생성: 재고 부족 시 예외 발생")
    void createOrder_shouldThrow_whenStockInsufficient() {
        List<OrderItem> items = List.of(new OrderItem("prod-2", 10)); // 재고: 5

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

    @Test
    @DisplayName("주문 취소: 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()); // 재고 복구
    }
}

// Step 2: 최소한의 구현 (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) {
        // 재고 확인 및 차감
        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());
            }
        }

        // 재고 차감 (도메인 로직)
        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");

        // 재고 복구
        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. 아키텍처 테스트 (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. 퀴즈

퀴즈 1: JUnit 5에서 @BeforeAll 메서드에 반드시 필요한 조건은?

보기:

  • A) 반드시 public이어야 한다
  • B) 반드시 static이어야 한다 (기본적으로)
  • C) 반드시 void를 반환해야 한다
  • D) 반드시 파라미터가 없어야 한다

정답: B (static이어야 한다)

설명: @BeforeAll 메서드는 기본적으로 static이어야 합니다. 클래스 전체에서 한 번만 실행되어야 하기 때문입니다. 단, @TestInstance(Lifecycle.PER_CLASS)를 사용하면 non-static @BeforeAll도 가능합니다. 반환 타입은 void여야 하고, @BeforeAll 메서드는 모든 접근 제어자(public, protected, package-private 등)를 사용할 수 있습니다.

퀴즈 2: Mockito의 @Spy와 @Mock의 차이는?

보기:

  • A) @Spy는 인터페이스에만 사용 가능하다
  • B) @Mock은 실제 메서드를 호출하지만 @Spy는 그렇지 않다
  • C) @Spy는 실제 객체를 감싸서 일부 메서드만 스텁할 수 있다
  • D) @Spy와 @Mock은 기능적으로 동일하다

정답: C

설명: @Mock은 완전한 모의 객체로 모든 메서드가 기본값을 반환합니다. @Spy는 실제 객체를 spy로 감싸기 때문에 기본적으로 실제 메서드가 호출되고, 일부 메서드만 스텁할 수 있습니다. @Spy는 구체적인 클래스 인스턴스가 필요합니다.

퀴즈 3: WireMock에서 시나리오(Scenario)를 사용하는 이유는?

보기:

  • A) 응답 속도를 높이기 위해
  • B) 상태에 따라 다른 응답을 반환하는 Stateful Mock을 구현하기 위해
  • C) 여러 테스트에서 동일한 스텁을 재사용하기 위해
  • D) HTTP 인증을 시뮬레이션하기 위해

정답: B

설명: WireMock 시나리오는 상태 기계(state machine)를 구현하여 동일한 요청에 대해 호출 순서에 따라 다른 응답을 반환합니다. 예를 들어 재시도 패턴(첫 호출은 503, 두 번째는 200) 또는 장바구니 상태 변화 테스트에 유용합니다.

퀴즈 4: @WebMvcTest와 @SpringBootTest의 주요 차이는?

보기:

  • A) @WebMvcTest는 실제 HTTP 요청을 사용하고 @SpringBootTest는 MockMvc를 사용한다
  • B) @WebMvcTest는 Web 레이어만 로드하여 빠르고, @SpringBootTest는 전체 컨텍스트를 로드한다
  • C) @SpringBootTest는 단위 테스트이고 @WebMvcTest는 통합 테스트이다
  • D) @WebMvcTest에서는 @MockBean을 사용할 수 없다

정답: B

설명: @WebMvcTest는 Controller, ControllerAdvice, Filter, WebMvcConfigurer 등 Web 레이어 관련 Bean만 로드하여 빠릅니다. @SpringBootTest는 전체 Spring 컨텍스트를 로드하므로 느리지만 실제 환경에 더 가깝습니다. 두 경우 모두 @MockBean 사용 가능합니다.

퀴즈 5: TestContainers의 주요 장점은?

보기:

  • A) 테스트 속도가 H2 in-memory DB보다 빠르다
  • B) 실제 데이터베이스 벤더의 동작을 정확히 재현하여 이식성이 높다
  • C) 외부 인프라 없이 테스트할 수 있어 CI/CD 파이프라인에 불필요하다
  • D) Spring Boot 설정 없이 사용할 수 있다

정답: B

설명: TestContainers의 주요 장점은 실제 PostgreSQL, MySQL, MongoDB, Kafka 등을 Docker 컨테이너로 구동하여 H2 같은 인메모리 DB로는 재현하기 어려운 벤더 특유의 동작(특수 SQL 문법, 트랜잭션 특성 등)을 정확히 테스트할 수 있다는 것입니다. 속도는 H2보다 느리지만 CI/CD 파이프라인에서도 잘 동작합니다.


마무리

효과적인 Java 테스트 전략:

  1. 단위 테스트: JUnit 5 + Mockito로 비즈니스 로직 집중 테스트
  2. 웹 레이어 테스트: @WebMvcTest + MockMvc로 빠른 API 테스트
  3. 통합 테스트: TestContainers로 실제 인프라와 동일한 환경
  4. HTTP 클라이언트 테스트: WireMock으로 외부 API 의존성 제거
  5. 아키텍처 테스트: ArchUnit으로 설계 원칙 자동 검증

테스트 피라미드를 지키면서 각 레이어에 적합한 도구를 선택하면 빠르고 신뢰할 수 있는 테스트 스위트를 구축할 수 있습니다.

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

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.