Skip to content
Published on

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

Authors

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으로 설계 원칙 자동 검증

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