테스트 코드는 프로덕션 코드만큼 중요합니다. 잘 작성된 테스트는 리팩토링의 안전망이고, 버그의 조기 감지망이며, 살아있는 명세서입니다. JUnit 5는 JUnit 4와 비교해 아키텍처부터 Extension 모델까지 전면 재설계되었습니다. 이 포스트에서는 JUnit 5의 핵심 기능을 깊이 파헤치고, 실무에서 바로 쓸 수 있는 테스트 전략까지 다룹니다.


1. JUnit 5 아키텍처 — 세 모듈의 역할

JUnit 4는 단일 JAR이었습니다. JUnit 5는 세 개의 독립 모듈로 분리됩니다.

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
graph LR
    IDE["IDE / Build Tool\n(IntelliJ, Gradle)"] -->|실행 요청| Platform["JUnit Platform\n테스트 엔진 런처\n테스트 탐색 API"]
    Platform --> Jupiter["JUnit Jupiter\n새로운 테스트 작성 API\n@Test, @ParameterizedTest\nExtension Model"]
    Platform --> Vintage["JUnit Vintage\nJUnit 3/4 하위 호환\n레거시 테스트 실행"]
    Platform --> Custom["Custom Engine\n(Spock, TestNG 등)"]

JUnit Platform: 테스트 엔진을 실행하는 런타임 기반. IDE와 빌드 툴이 이 API를 통해 테스트를 탐색하고 실행합니다.

JUnit Jupiter: 우리가 직접 쓰는 테스트 작성 API입니다. @Test, @ParameterizedTest, @BeforeEach, Extension 모델 전부 여기에 속합니다.

JUnit Vintage: JUnit 3/4로 작성된 레거시 테스트를 Platform 위에서 실행할 수 있게 해주는 어댑터입니다.

의존성 설정

// build.gradle
dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:5.4.0'

    // Spring Boot는 spring-boot-starter-test에 포함되어 있음
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform() // Platform을 통해 Jupiter 엔진 실행
}

2. @ParameterizedTest — 반복 테스트의 끝판왕

왜 필요한가

이메일 유효성 검사를 테스트한다고 가정합니다. 정상 케이스, 잘못된 형식 케이스, 빈 문자열 케이스, null 케이스를 각각 별도 테스트로 작성하면 코드가 중복됩니다. @ParameterizedTest하나의 테스트 로직에 여러 입력값을 주입해 중복 없이 다양한 케이스를 검증합니다.

@ValueSource — 단순 값 주입

@ParameterizedTest
@ValueSource(strings = {"user@example.com", "admin@test.org", "support@company.co.kr"})
void 유효한_이메일_형식은_검증을_통과한다(String email) {
    assertThat(emailValidator.isValid(email)).isTrue();
}

@ParameterizedTest
@ValueSource(ints = {1, 5, 10, 100, Integer.MAX_VALUE})
void 양수는_상품_수량으로_유효하다(int quantity) {
    assertThat(productValidator.isValidQuantity(quantity)).isTrue();
}

// null과 빈 문자열 추가
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"invalid", "no-at-sign", "@nodomain", "double@@sign.com"})
void 잘못된_이메일은_검증에_실패한다(String email) {
    assertThat(emailValidator.isValid(email)).isFalse();
}

@CsvSource — 입력과 기대값을 함께

@ParameterizedTest(name = "{0}원 주문에 {1} 할인 적용 시 {2}원")
@CsvSource({
    "10000, VIP,     8000",
    "10000, COUPON,  9000",
    "10000, EVENT,   7000",
    "10000, NONE,   10000",
    "0,     VIP,       0"  // 경계값
})
void 할인_정책별_금액_계산(double price, String discountType, double expected) {
    double result = discountService.applyDiscount(discountType, price);
    assertThat(result).isEqualTo(expected);
}

@MethodSource — 복잡한 객체 주입

@ParameterizedTest
@MethodSource("provideOrderScenarios")
void 주문_상태_전이_시나리오(OrderStatus from, String action, OrderStatus expectedTo) {
    Order order = new Order(from);
    order.transition(action);
    assertThat(order.getStatus()).isEqualTo(expectedTo);
}

// 같은 클래스의 static 메서드
static Stream<Arguments> provideOrderScenarios() {
    return Stream.of(
        Arguments.of(OrderStatus.PENDING, "pay",     OrderStatus.PAID),
        Arguments.of(OrderStatus.PAID,    "ship",    OrderStatus.SHIPPED),
        Arguments.of(OrderStatus.SHIPPED, "deliver", OrderStatus.DELIVERED),
        Arguments.of(OrderStatus.PENDING, "cancel",  OrderStatus.CANCELLED)
    );
}

@ArgumentsSource — 재사용 가능한 ArgumentsProvider

// 프로젝트 전반에서 재사용할 경계값 Provider
public class BoundaryValueProvider implements ArgumentsProvider {

    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return Stream.of(
            Arguments.of(Integer.MIN_VALUE, "최솟값"),
            Arguments.of(-1,               "음수 경계"),
            Arguments.of(0,                "영"),
            Arguments.of(1,                "양수 경계"),
            Arguments.of(Integer.MAX_VALUE,"최댓값")
        );
    }
}

@ParameterizedTest(name = "입력={0} ({1})")
@ArgumentsSource(BoundaryValueProvider.class)
void 경계값_테스트(int value, String description) {
    // 경계값에 대한 검증
}

@EnumSource — Enum 기반 테스트

@ParameterizedTest
@EnumSource(value = OrderStatus.class, names = {"PENDING", "PAID"})
void 취소_가능한_상태에서_취소가_성공한다(OrderStatus cancellableStatus) {
    Order order = new Order(cancellableStatus);
    assertThatNoException().isThrownBy(() -> order.cancel());
}

@ParameterizedTest
@EnumSource(value = OrderStatus.class, names = {"SHIPPED", "DELIVERED"}, mode = Mode.EXCLUDE)
void 배송_이후_상태_외_모든_상태에서_취소가_허용된다(OrderStatus status) {
    // SHIPPED, DELIVERED를 제외한 모든 OrderStatus 케이스 실행
}

3. Extension Model — JUnit 5의 핵심 확장 메커니즘

JUnit 4의 @RunWith@Rule을 통합하고 확장한 것이 JUnit 5의 Extension Model입니다. 하나의 @ExtendWith로 다양한 확장 지점(Extension Point)을 구현할 수 있습니다.

주요 Extension 콜백 지점

테스트 클래스 생성
    ↓ TestInstanceFactory
    ↓ TestInstancePostProcessor
BeforeAllCallback
    ↓ BeforeEachCallback
    ↓ BeforeTestExecutionCallback
        [테스트 메서드 실행]
    ↓ AfterTestExecutionCallback
    ↓ AfterEachCallback
AfterAllCallback

실전 Extension — 실행 시간 측정

// 테스트 실행 시간을 측정해 경고를 출력하는 Extension
public class SlowTestExtension
        implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private static final long THRESHOLD_MS = 1000; // 1초 초과 시 경고
    private static final String START_TIME_KEY = "startTime";

    @Override
    public void beforeTestExecution(ExtensionContext context) {
        getStore(context).put(START_TIME_KEY, System.currentTimeMillis());
    }

    @Override
    public void afterTestExecution(ExtensionContext context) {
        long startTime = getStore(context).remove(START_TIME_KEY, long.class);
        long duration = System.currentTimeMillis() - startTime;

        if (duration > THRESHOLD_MS) {
            System.out.printf(
                "[SLOW TEST] %s.%s took %dms%n",
                context.getRequiredTestClass().getSimpleName(),
                context.getRequiredTestMethod().getName(),
                duration
            );
        }
    }

    private ExtensionContext.Store getStore(ExtensionContext context) {
        return context.getStore(
            ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod())
        );
    }
}

// 사용
@ExtendWith(SlowTestExtension.class)
class OrderServiceTest { ... }

실전 Extension — ParameterResolver (의존성 주입)

// 테스트 메서드 파라미터에 자동으로 객체를 주입하는 Extension
public class RandomOrderExtension implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext paramCtx,
                                     ExtensionContext extCtx) {
        return paramCtx.getParameter().isAnnotationPresent(RandomOrder.class);
    }

    @Override
    public Object resolveParameter(ParameterContext paramCtx,
                                   ExtensionContext extCtx) {
        return Order.builder()
            .id(new Random().nextLong())
            .status(OrderStatus.PENDING)
            .totalAmount(ThreadLocalRandom.current().nextDouble(1000, 100000))
            .build();
    }
}

// 커스텀 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface RandomOrder {}

// 사용
@ExtendWith(RandomOrderExtension.class)
class OrderTest {

    @Test
    void 무작위_주문에_결제를_처리할_수_있다(@RandomOrder Order order) {
        // order는 Extension이 생성한 무작위 Order
        assertThatNoException().isThrownBy(() -> paymentService.process(order));
    }
}

실전 Extension — TestExecutionCondition (조건부 실행)

// 특정 환경에서만 테스트를 실행하는 Extension
public class ProfileCondition implements ExecutionCondition {

    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
        Optional<EnableOnProfile> annotation = findAnnotation(
            context.getElement(), EnableOnProfile.class
        );

        return annotation.map(a -> {
            String activeProfile = System.getProperty("spring.profiles.active", "test");
            if (Arrays.asList(a.value()).contains(activeProfile)) {
                return ConditionEvaluationResult.enabled("Profile matched: " + activeProfile);
            }
            return ConditionEvaluationResult.disabled(
                "Disabled: active profile is " + activeProfile
            );
        }).orElse(ConditionEvaluationResult.enabled("No @EnableOnProfile annotation"));
    }
}

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(ProfileCondition.class)
public @interface EnableOnProfile {
    String[] value();
}

// 사용 — CI 환경에서만 실행
@Test
@EnableOnProfile("ci")
void 통합_결제_API_호출_테스트() {
    // CI 프로필일 때만 실행, 로컬에서는 skip
}

4. @Nested — 테스트 구조화의 기술

@Nested는 테스트를 계층적으로 구조화합니다. 하나의 클래스를 기준으로 “상황(Given)에 따른 행동(When)과 결과(Then)”를 그룹화합니다.

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

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

        @Test
        @DisplayName("재고가 충분하면 주문이 생성된다")
        void 재고_충분_주문_생성() { ... }

        @Test
        @DisplayName("재고가 부족하면 예외가 발생한다")
        void 재고_부족_예외() { ... }

        @Nested
        @DisplayName("VIP 회원이 주문할 때")
        class VIP_회원_주문 {

            @BeforeEach
            void VIP_회원_설정() {
                // 각 테스트 전에 VIP 회원 셋업
            }

            @Test
            @DisplayName("20% 할인이 자동 적용된다")
            void VIP_할인_자동_적용() { ... }

            @Test
            @DisplayName("무료 배송이 제공된다")
            void VIP_무료_배송() { ... }
        }
    }

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

        @Test
        @DisplayName("결제 전 주문은 취소 가능하다")
        void 결제_전_취소() { ... }

        @Test
        @DisplayName("배송 중 주문은 취소 불가능하다")
        void 배송_중_취소_불가() { ... }
    }
}

테스트 결과 창에서 이렇게 표시됩니다.

주문 서비스
├── 주문 생성 시
│   ├── ✓ 재고가 충분하면 주문이 생성된다
│   ├── ✓ 재고가 부족하면 예외가 발생한다
│   └── VIP 회원이 주문할 때
│       ├── ✓ 20% 할인이 자동 적용된다
│       └── ✓ 무료 배송이 제공된다
└── 주문 취소 시
    ├── ✓ 결제 전 주문은 취소 가능하다
    └── ✓ 배송 중 주문은 취소 불가능하다

@Nested 클래스의 특성: 비static 내부 클래스이므로 외부 클래스의 필드에 접근 가능합니다. @BeforeAll은 사용 불가(static 메서드 불가이므로) — @TestInstance(Lifecycle.PER_CLASS)로 해결.


5. @TestFactory + DynamicTest — 런타임에 테스트 생성

@ParameterizedTest는 컴파일 타임에 파라미터가 결정되어야 합니다. @TestFactoryDynamicTest런타임에 테스트를 동적으로 생성합니다. DB에서 테스트 데이터를 읽거나, 파일에서 시나리오를 로드하는 경우에 유용합니다.

@TestFactory
Stream<DynamicTest> 할인_정책_동적_테스트() {
    // 런타임에 DB나 파일에서 테스트 케이스를 로드
    List<DiscountTestCase> testCases = loadTestCasesFromDb();

    return testCases.stream()
        .map(tc -> DynamicTest.dynamicTest(
            tc.getDescription(), // 테스트 이름
            () -> {              // 실행 로직
                double result = discountService.calculate(tc.getType(), tc.getPrice());
                assertThat(result).isEqualTo(tc.getExpected());
            }
        ));
}

@TestFactory
Collection<DynamicNode> 상태_전이_트리() {
    return List.of(
        DynamicContainer.dynamicContainer("결제 흐름",
            Stream.of(
                DynamicTest.dynamicTest("PENDING → PAID", () -> {
                    Order order = new Order(OrderStatus.PENDING);
                    order.pay();
                    assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
                }),
                DynamicTest.dynamicTest("PAID → SHIPPED", () -> {
                    Order order = new Order(OrderStatus.PAID);
                    order.ship();
                    assertThat(order.getStatus()).isEqualTo(OrderStatus.SHIPPED);
                })
            )
        ),
        DynamicContainer.dynamicContainer("취소 흐름",
            Stream.of(
                DynamicTest.dynamicTest("PENDING → CANCELLED", () -> {
                    Order order = new Order(OrderStatus.PENDING);
                    order.cancel();
                    assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
                })
            )
        )
    );
}

6. 핵심 Assertion — assertAll, assertThrows, assertTimeout

assertAll — 실패해도 모든 검증 실행

일반 assertEquals는 첫 번째 실패에서 중단됩니다. assertAll모든 검증을 실행한 뒤 결과를 한꺼번에 보고합니다.

@Test
void 주문_생성_후_모든_필드_검증() {
    Order order = orderService.createOrder(createRequest());

    assertAll("주문 객체 검증",
        () -> assertThat(order.getId()).isNotNull(),
        () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING),
        () -> assertThat(order.getTotalAmount()).isPositive(),
        () -> assertThat(order.getMember()).isNotNull(),
        () -> assertThat(order.getCreatedAt()).isBeforeOrEqualTo(LocalDateTime.now())
    );
    // 5개 중 3개가 실패해도 5개 모두 실행하고 3개 실패를 한 번에 보고
}

assertThrows — 예외 타입과 메시지 검증

@Test
void 재고_부족_시_예외_메시지_검증() {
    // assertThrows는 예외 객체를 반환
    InsufficientStockException exception = assertThrows(
        InsufficientStockException.class,
        () -> orderService.createOrder(new OrderRequest(productId, 9999))
    );

    assertThat(exception.getMessage())
        .contains("재고가 부족합니다")
        .contains(String.valueOf(productId));
    assertThat(exception.getAvailableStock()).isLessThan(9999);
}

// AssertJ의 assertThatThrownBy — 더 유연한 예외 검증
@Test
void 권한_없는_사용자_주문_취소_예외() {
    assertThatThrownBy(() -> orderService.cancel(orderId, unauthorizedUserId))
        .isInstanceOf(AccessDeniedException.class)
        .hasMessageContaining("권한이 없습니다")
        .hasFieldOrPropertyWithValue("userId", unauthorizedUserId);
}

assertTimeout — 성능 기준 테스트

@Test
void 주문_목록_조회는_1초_이내에_완료된다() {
    // assertTimeout: 시간 초과 시 테스트 실패, 실행은 완료될 때까지 대기
    assertTimeout(Duration.ofSeconds(1), () -> {
        orderService.findAll(PageRequest.of(0, 100));
    });
}

@Test
void 대량_주문_처리는_500ms_이내에_중단된다() {
    // assertTimeoutPreemptively: 시간 초과 시 즉시 중단
    assertTimeoutPreemptively(Duration.ofMillis(500), () -> {
        orderBatchService.processLargeOrders();
    }, "대량 주문 처리가 500ms를 초과했습니다");
}

7. Mockito + BDDMockito 연동

Mockito 기본 패턴

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentClient paymentClient;

    @InjectMocks
    private OrderService orderService;

    @Test
    void 결제_성공_시_주문_상태가_PAID로_변경된다() {
        // given
        Order order = new Order(1L, OrderStatus.PENDING, 10000.0);
        when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
        when(paymentClient.pay(any(PaymentRequest.class)))
            .thenReturn(new PaymentResult(true, "TX_123"));

        // when
        orderService.pay(1L);

        // then
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
        verify(orderRepository).save(order);
        verify(paymentClient).pay(argThat(req -> req.getAmount() == 10000.0));
    }
}

BDDMockito — 더 읽기 좋은 BDD 스타일

@ExtendWith(MockitoExtension.class)
class OrderServiceBDDTest {

    @Mock private OrderRepository orderRepository;
    @Mock private PaymentClient paymentClient;
    @InjectMocks private OrderService orderService;

    @Test
    void BDD_스타일로_결제_성공_테스트() {
        // given — BDDMockito.given() 사용
        Order order = new Order(1L, OrderStatus.PENDING, 10000.0);
        given(orderRepository.findById(1L)).willReturn(Optional.of(order));
        given(paymentClient.pay(any())).willReturn(new PaymentResult(true, "TX_123"));

        // when
        orderService.pay(1L);

        // then — BDDMockito.then() 사용
        then(orderRepository).should().save(order);
        then(paymentClient).should(times(1)).pay(any());
        then(orderRepository).should(never()).delete(any());
    }

    @Test
    void 결제_실패_시_예외가_전파된다() {
        // given
        Order order = new Order(1L, OrderStatus.PENDING, 10000.0);
        given(orderRepository.findById(1L)).willReturn(Optional.of(order));
        given(paymentClient.pay(any()))
            .willThrow(new PaymentException("카드 한도 초과"));

        // when & then
        assertThatThrownBy(() -> orderService.pay(1L))
            .isInstanceOf(PaymentException.class)
            .hasMessageContaining("카드 한도 초과");

        then(orderRepository).should(never()).save(any());
    }
}

ArgumentCaptor — 전달된 인자 검증

@Test
void 주문_저장_시_올바른_타임스탬프가_설정된다() {
    ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class);

    given(orderRepository.findById(1L))
        .willReturn(Optional.of(new Order(1L, OrderStatus.PENDING, 10000.0)));
    given(paymentClient.pay(any())).willReturn(new PaymentResult(true, "TX"));

    orderService.pay(1L);

    verify(orderRepository).save(orderCaptor.capture());
    Order savedOrder = orderCaptor.getValue();

    assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PAID);
    assertThat(savedOrder.getPaidAt())
        .isAfter(LocalDateTime.now().minusSeconds(1));
}

8. 테스트 격리 전략

@Transactional — 가장 일반적인 DB 격리

@SpringBootTest
@Transactional  // 각 테스트 후 롤백
class OrderIntegrationTest {

    @Autowired private OrderService orderService;
    @Autowired private OrderRepository orderRepository;

    @Test
    void 주문_생성_후_조회() {
        // given
        Order created = orderService.createOrder(new OrderRequest(1L, 2));

        // when
        Order found = orderRepository.findById(created.getId()).orElseThrow();

        // then
        assertThat(found.getStatus()).isEqualTo(OrderStatus.PENDING);
    }
    // 테스트 종료 후 자동 롤백 → DB 초기화
}

주의: @Transactional 테스트에서 @Async 메서드나 별도 트랜잭션(REQUIRES_NEW)으로 저장된 데이터는 롤백되지 않습니다.

@DirtiesContext — 비용이 큰 마지막 수단

@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class StatefulBeanTest {
    // 각 테스트 후 Spring ApplicationContext 재생성
    // 매우 느림 — 꼭 필요한 경우에만 사용
}

Testcontainers — 실제 DB로 통합 테스트

도커 컨테이너로 실제 DB를 띄워 테스트합니다. 인메모리 DB(H2)와 실제 DB의 동작 차이를 없앨 수 있습니다.

@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }

    @Autowired private OrderRepository orderRepository;

    @Test
    void MySQL_실제_DB로_주문_저장_조회() {
        Order order = Order.builder()
            .status(OrderStatus.PENDING)
            .totalAmount(10000.0)
            .build();

        Order saved = orderRepository.save(order);
        Order found = orderRepository.findById(saved.getId()).orElseThrow();

        assertThat(found.getTotalAmount()).isEqualTo(10000.0);
    }
}

컨테이너 공유로 성능 최적화:

// 추상 클래스로 공통 컨테이너 설정 — 모든 통합 테스트가 공유
@Testcontainers
public abstract class IntegrationTestBase {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withReuse(true); // 동일 컨테이너 재사용

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        // ...
    }
}

// 상속해서 사용
class OrderRepositoryTest extends IntegrationTestBase {
    @Test void 테스트1() { ... }
}

class MemberRepositoryTest extends IntegrationTestBase {
    @Test void 테스트2() { ... }
    // 같은 컨테이너 인스턴스 재사용 → 빠름
}

9. 테스트 커버리지 — 무엇을 테스트할 것인가

3가지 기본 시나리오

좋은 테스트는 세 가지 시나리오를 반드시 다룹니다.

행복 경로(Happy Path): 모든 것이 정상일 때 기대 결과가 나오는가.

@Test
void 정상_주문_생성() {
    // 재고 충분, 회원 정상, 결제 수단 유효 → 주문 생성 성공
}

실패 경로(Failure Path): 비즈니스 규칙 위반, 외부 시스템 장애 등 예외 상황.

@Test
void 재고_부족_시_주문_실패() { ... }

@Test
void 결제_시스템_장애_시_예외_처리() { ... }

@Test
void 만료된_쿠폰_적용_시_예외() { ... }

경계값(Boundary Value): 0, 1, 최댓값, 최솟값, null, 빈 컬렉션.

@ParameterizedTest
@ValueSource(ints = {0, -1, Integer.MIN_VALUE})
void 수량이_0_이하면_주문_불가(int quantity) { ... }

@Test
void 빈_장바구니로_주문_불가() {
    assertThatThrownBy(() -> orderService.createOrder(List.of()))
        .isInstanceOf(EmptyCartException.class);
}

커버리지 숫자보다 중요한 것

라인 커버리지 80%가 목표인데, 중요한 비즈니스 로직은 50%이고 getter/setter가 100%라면 의미 없습니다.

// 이런 테스트는 커버리지를 높이지만 의미가 없음
@Test
void 게터_세터_테스트() {
    Order order = new Order();
    order.setId(1L);
    assertThat(order.getId()).isEqualTo(1L); // 아무것도 테스트하지 않음
}

진짜 중요한 것은 핵심 비즈니스 로직의 분기 커버리지(Branch Coverage)입니다. 모든 if/else, switch 분기를 테스트했는가를 확인합니다.

# build.gradle의 JaCoCo 설정
jacocoTestCoverageVerification {
    violationRules {
        rule {
            element = 'CLASS'
            // 비즈니스 로직 패키지만 커버리지 강제
            includes = ['com.example.domain.*', 'com.example.service.*']
            limit {
                counter = 'BRANCH'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }
        }
    }
}

극한 시나리오 3가지

시나리오 1: @ParameterizedTest + @SpringBootTest = ApplicationContext 폭발

@SpringBootTest를 사용하면 ApplicationContext를 띄웁니다. @ParameterizedTest와 함께 쓰면 케이스 수만큼 Context가 재생성될 수 있습니다.

// 위험: 100개 케이스 × ApplicationContext 재생성 = 극도로 느린 테스트
@SpringBootTest
@ParameterizedTest
@CsvSource({/* 100개 케이스 */})
void 통합_테스트(String input, String expected) { ... }

해결책: @SpringBootTest는 기본적으로 Context를 캐싱합니다. @DirtiesContext를 붙이지 않으면 동일 Context를 재사용합니다. 단 @MockBean을 사용하면 새 Context를 생성합니다. @MockBean 남용을 피하고, 통합 테스트용 공통 Base Class를 만들어 동일 Context를 공유하세요.

시나리오 2: Mockito와 @Transactional 프록시 충돌

Spring의 @Transactional은 AOP 프록시로 동작합니다. @InjectMocks로 Service를 직접 생성하면 프록시가 없어 @Transactional이 동작하지 않습니다.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @InjectMocks
    private OrderService orderService; // 프록시 없음 → @Transactional 무시
    // 트랜잭션 없이 JPA 작업하면 LazyInitializationException 발생 가능
}

해결책: 트랜잭션 동작을 테스트해야 한다면 @SpringBootTest + @Autowired를 사용합니다. 단순 비즈니스 로직만 테스트한다면 @ExtendWith(MockitoExtension.class) + @InjectMocks로 충분합니다. 두 가지를 분리하세요.

시나리오 3: Testcontainers 병렬 실행 시 포트 충돌

JUnit 5는 기본적으로 테스트 클래스를 순차 실행하지만, junit.jupiter.execution.parallel.enabled=true로 병렬 실행을 켜면 여러 컨테이너가 같은 포트를 점유하려 합니다.

# 병렬 실행 설정
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent

해결책: @Container static으로 선언하면 테스트 클래스당 하나의 컨테이너만 생성됩니다. 컨테이너에 고정 포트를 쓰지 않고 랜덤 포트를 사용하면 충돌을 피할 수 있습니다. MySQL 컨테이너의 호스트 포트는 항상 랜덤이므로 mysql::getJdbcUrl을 사용하면 안전합니다.


실무 실수 Top 5

1. 테스트 메서드 이름을 test1, test2로 짓기

// Bad
@Test
void test1() { ... }

// Good — 무엇을 테스트하는지 명확히
@Test
@DisplayName("재고가 충분한 경우 주문 생성이 성공한다")
void 재고_충분_주문_생성_성공() { ... }

2. 하나의 테스트에 여러 시나리오 넣기

// Bad: 하나의 테스트에 결제 성공과 실패를 모두 검증
@Test
void 결제_테스트() {
    // 성공 케이스
    assertThat(paymentService.pay(validCard, 1000)).isTrue();
    // 실패 케이스
    assertThat(paymentService.pay(invalidCard, 1000)).isFalse();
    // 첫 번째가 실패하면 두 번째는 실행되지 않음
}

// Good: 각 시나리오를 별도 테스트로
@Test void 유효한_카드로_결제_성공() { ... }
@Test void 유효하지_않은_카드로_결제_실패() { ... }

3. given/when/then 구분 없이 순서대로 작성

가독성 저하의 주요 원인입니다. 주석으로라도 // given, // when, // then을 반드시 구분하세요.

4. Mock 객체에 verify 없이 상태만 검증

// 불완전한 테스트
@Test
void 결제_성공_테스트() {
    orderService.pay(1L);
    assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
    // 결제 API가 실제로 호출됐는지 검증하지 않음!
}

// 완전한 테스트
@Test
void 결제_성공_테스트() {
    orderService.pay(1L);
    assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
    verify(paymentClient).pay(any()); // 외부 호출 검증 추가
}

5. @SpringBootTest를 기본으로 쓰기

@SpringBootTest는 전체 Application Context를 로드합니다. 단순 서비스 로직 테스트에 @SpringBootTest를 쓰면 테스트가 10배 이상 느려집니다. 계층별로 적절한 슬라이스 테스트를 사용하세요.

// Controller 테스트 — MVC 레이어만 로드
@WebMvcTest(OrderController.class)
class OrderControllerTest { ... }

// Repository 테스트 — JPA 레이어만 로드
@DataJpaTest
class OrderRepositoryTest { ... }

// Service 테스트 — Spring Context 불필요
@ExtendWith(MockitoExtension.class)
class OrderServiceTest { ... }

// 전체 통합 테스트 — 진짜 필요한 경우에만
@SpringBootTest
class OrderIntegrationTest { ... }

면접 포인트 5가지

Q1. JUnit 5의 Extension Model이 JUnit 4의 @RunWith/@Rule과 어떻게 다른가?

JUnit 4는 @RunWith로 단 하나의 Runner만 지정할 수 있었습니다. Spring 테스트(SpringJUnit4ClassRunner)와 Mockito(MockitoJUnitRunner)를 동시에 사용하려면 불편한 우회법이 필요했습니다. JUnit 5의 @ExtendWith여러 Extension을 동시에 등록할 수 있습니다. @ExtendWith({SpringExtension.class, MockitoExtension.class})처럼 조합이 자유롭습니다. 또한 Extension이 구현할 수 있는 콜백 지점(BeforeEachCallback, ParameterResolver 등)이 세분화되어 원하는 동작만 선택적으로 구현할 수 있습니다.

Q2. @Mock과 @MockBean의 차이는?

@Mock은 Mockito가 제공하는 순수 Mock 객체입니다. Spring ApplicationContext와 무관하며 @ExtendWith(MockitoExtension.class)와 함께 단위 테스트에서 사용합니다.

@MockBean은 Spring Boot Test가 제공합니다. Spring ApplicationContext 내의 실제 Bean을 Mock으로 교체합니다. @SpringBootTest@WebMvcTest와 함께 사용합니다. 중요 주의사항: @MockBean을 사용하면 해당 테스트 클래스에 맞춰 ApplicationContext를 새로 생성하므로 Context 캐싱이 깨집니다. 같은 MockBean 설정을 가진 테스트끼리 묶거나, @MockBean을 Base Class로 옮겨 캐시 히트를 유도하세요.

Q3. @Transactional 테스트의 주의사항은?

@Transactional 테스트는 각 테스트 후 롤백되어 편리하지만, 실제 운영 환경과 다른 동작을 보일 수 있습니다.

첫째, @Async 메서드가 별도 스레드에서 실행되면 테스트 트랜잭션에 참여하지 않아 롤백되지 않습니다. 둘째, REQUIRES_NEW 전파 속성으로 열린 트랜잭션도 독립적으로 커밋되어 롤백 대상이 아닙니다. 셋째, @EventListener로 처리되는 이벤트가 별도 트랜잭션에서 실행되면 테스트 종료 후 데이터가 남습니다. 이런 경우엔 Testcontainers를 사용하고 @Sql로 데이터를 직접 관리하는 방식을 선택합니다.

Q4. ParameterizedTest에서 null을 테스트하는 방법은?

@ValueSource는 null을 직접 넣을 수 없습니다. 대신 세 가지 방법을 씁니다.

@NullSource는 null만 단독으로 주입합니다. @EmptySource는 빈 문자열/컬렉션을 주입합니다. @NullAndEmptySource는 둘 다 주입합니다. 이들을 @ValueSource와 함께 조합할 수 있습니다.

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void 공백_null_빈값은_모두_유효하지_않다(String value) {
    assertThat(validator.isValid(value)).isFalse();
}

Q5. 테스트 커버리지 80%를 달성했는데 버그가 계속 나온다. 왜인가?

라인 커버리지는 해당 라인이 실행됐는지만 확인합니다. 실행됐다고 해서 올바른 결과가 나왔다는 뜻은 아닙니다. 흔한 원인은 세 가지입니다.

첫째, assertThat(result) 없이 그냥 실행만 하는 테스트입니다. 라인은 커버되지만 아무것도 검증하지 않습니다. 둘째, 분기 커버리지가 낮은 경우입니다. if/else 중 한 쪽만 테스트해도 라인 커버리지는 올라갑니다. 셋째, 경계값 미테스트입니다. quantity > 0인 조건에서 quantity = 0, quantity = -1을 테스트하지 않으면 경계에서 터집니다. 커버리지 숫자보다 핵심 비즈니스 분기의 모든 케이스를 테스트했는가에 집중하세요.


JUnit 5 테스트 흐름 한눈에 보기

sequenceDiagram
    participant Gradle
    participant Platform
    participant Jupiter
    participant Extension
    participant Test

    Gradle->>Platform: useJUnitPlatform()
    Platform->>Jupiter: 테스트 탐색 (TestEngine)
    Jupiter->>Extension: BeforeAllCallback
    loop 각 테스트 메서드
        Jupiter->>Extension: BeforeEachCallback
        Jupiter->>Extension: ParameterResolver (파라미터 주입)
        Jupiter->>Test: @Test 메서드 실행
        Test-->>Jupiter: 성공/실패
        Jupiter->>Extension: AfterEachCallback
    end
    Jupiter->>Extension: AfterAllCallback
    Jupiter-->>Platform: 결과 집계
    Platform-->>Gradle: 테스트 보고서
sequenceDiagram
    participant TestMethod
    participant MockitoExtension
    participant Mock
    participant SUT

    TestMethod->>MockitoExtension: @Mock 필드 주입 요청
    MockitoExtension->>Mock: Mock 객체 생성
    Mock-->>MockitoExtension: OrderRepository Mock
    MockitoExtension-->>TestMethod: @InjectMocks에 주입

    TestMethod->>Mock: given(repo.findById(1L)).willReturn(order)
    TestMethod->>SUT: orderService.pay(1L)
    SUT->>Mock: repository.findById(1L)
    Mock-->>SUT: order (stubbed)
    SUT-->>TestMethod: 처리 완료

    TestMethod->>Mock: then(repo).should().save(order)
    Mock-->>TestMethod: 호출 검증 완료

JUnit 5는 도구입니다. 도구를 아무리 잘 써도 테스트할 대상을 잘못 선택하면 의미가 없습니다. “이 코드가 실패하면 비즈니스에 영향을 주는가?”를 기준으로 테스트 우선순위를 정하세요. 중요한 비즈니스 규칙은 철저하게, getter/setter는 생략하는 것이 현명한 전략입니다.

카테고리:

업데이트:

댓글