좋은 Unit Test: 이름 지정, 구조 및 커버리지

1. 개요

좋은 Unit Test는 단순히 “통과하는” 테스트가 아닙니다. 읽기 쉽고 유지보수하기 좋으며, 실패했을 때 하루 종일 디버깅할 필요 없이 문제가 어디에 있는지 바로 알 수 있어야 합니다. 이 글에서는 세 가지 기둥을 다룹니다: 자체 설명이 되는 테스트 이름 지정, AAA(Arrange-Act-Assert) 구조에 따른 테스트 구성, 그리고 “100% 커버리지인데 무의미하다”는 함정에 빠지지 않기 위한 올바른 커버리지 관점입니다.

이 글을 마치면, Pull Request를 생성하기 전에 자신의 테스트를 검토할 구체적인 체크리스트를 갖게 될 것입니다.

2. 필요한 Dependency

이 글의 예제는 JUnit 5와 AssertJ를 사용합니다. Spring Boot 프로젝트라면 spring-boot-starter-test에 이미 두 라이브러리가 포함되어 있습니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. 테스트 이름 지정: 이름이 이야기를 말해야 함

테스트 이름은 테스트가 실패했을 때 다른 사람이 가장 먼저 읽는 문서입니다. 좋은 이름은 세 가지 질문에 답합니다: 무엇을 테스트하는가, 어떤 조건에서, 그리고 무엇을 기대하는가.

3.1. methodName_condition_expectedResult 패턴

class OrderServiceTest {

    @Test
    void calculateTotal_whenCartIsEmpty_returnsZero() {
        OrderService service = new OrderService();
        Order order = new Order(List.of());

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.ZERO);
    }

    @Test
    void calculateTotal_whenCartHasDiscountedItems_appliesDiscount() {
        OrderService service = new OrderService();
        Order order = new Order(List.of(
                new Item("SKU-1", BigDecimal.valueOf(100), 0.1)
        ));

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.valueOf(90.0));
    }
}

설명:

  • 이름은 테스트 중인 메서드(calculateTotal)로 시작하고, when...으로 조건을 명시한 후, 마지막으로 예상되는 결과를 나타냅니다.
  • CI 보고서에서 테스트가 실패하면 이름만 읽어도 어느 로직 분기에서 오류가 났는지 즉시 알 수 있습니다. 코드를 열 필요가 없습니다.

주의: test1(), testCalculateTotal() 같은 이름은 피하세요. 이런 이름들은 아무 정보도 주지 않으며, 독자가 테스트 본체를 봐야만 무엇이 확인되고 있는지 알 수 있습니다.

3.2. 메서드 이름이 충분하지 않을 때 @DisplayName 사용

비즈니스 규칙이 복잡하면 메서드 이름이 길어져 읽기 어려워집니다. @DisplayName을 사용하여 더 자연스러운 문장으로 작성하고, 코드 네비게이션을 위해 메서드 이름은 짧게 유지하세요.

@Test
@DisplayName("고객이 VIP이고 주문액이 100만 원 이상일 때 추가로 5% 할인")
void calculateTotal_vipCustomerLargeOrder_extraDiscount() {
    // ...
}

Maven Surefire 또는 IDE에서 실행할 때 출력에 메서드 이름 대신 @DisplayName의 문구가 표시되어 보고서에서 읽기 쉬워집니다.

4. AAA 구조: Arrange – Act – Assert

각 테스트는 세 가지 부분으로 명확하게 나뉘어야 하며, 순서를 지켜야 합니다. 이를 통해 독자가 어느 부분이 데이터를 준비하고, 어느 부분이 동작을 호출하며, 어느 부분이 결과를 확인하는지 빠르게 파악할 수 있습니다.

@Test
void withdraw_whenBalanceIsSufficient_deductsAmount() {
    // Arrange
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));

    // Act
    account.withdraw(BigDecimal.valueOf(200));

    // Assert
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(300));
}

설명:

  • Arrange는 초기 잔액으로 Account 객체를 생성합니다.
  • Act는 테스트 중인 정확히 하나의 동작(withdraw)을 호출합니다.
  • Assert는 결과를 확인하며, AssertJ를 사용하여 자연스러운 문장처럼 읽히는 assertion을 작성합니다.

4.1. 하나의 테스트, 하나의 동작

테스트가 할수록 많은 일을 할수록 실패했을 때 디버깅이 어려워집니다. 다음과 같은 테스트를 작성하는 것을 피하세요:

@Test
void accountOperations() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));
    account.deposit(BigDecimal.valueOf(100));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(600));

    account.withdraw(BigDecimal.valueOf(200));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));

    account.withdraw(BigDecimal.valueOf(1000)); // 오류: 잔액 부족
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
}

첫 번째 assertion이 실패하면 뒤의 두 동작은 실행되지 않아 정보를 잃게 됩니다. 대신 세 개의 별도 테스트로 나누세요: deposit_..., withdraw_sufficientBalance_..., withdraw_insufficientBalance_throwsException.

4.2. Exception 테스트는 올바른 타입과 메시지를 확인해야 함

@Test
void withdraw_whenBalanceIsInsufficient_throwsIllegalStateException() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(100));

    assertThatThrownBy(() -> account.withdraw(BigDecimal.valueOf(500)))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("Insufficient balance");
}

assertThatThrownBy 라인은 동작을 호출하고 exception을 한 번에 캡처합니다. 오래된 @Test(expected = ...)는 사용하지 마세요. 이 방식은 exception 후의 메시지나 상태를 확인할 수 없기 때문입니다.

5. @ParameterizedTest를 사용하여 반복 코드 줄이기

같은 로직을 여러 입력값으로 검증할 때는 parameterized test를 사용하세요. 복수의 메서드를 복사-붙여넣기하는 대신 말입니다.

@ParameterizedTest
@CsvSource({
    "0, 0",
    "100, 10",
    "1000, 100",
    "-50, 0"
})
void calculateShippingFee_variousOrderAmounts_returnsExpectedFee(
        BigDecimal orderAmount, BigDecimal expectedFee) {

    ShippingCalculator calculator = new ShippingCalculator();

    BigDecimal fee = calculator.calculateFee(orderAmount);

    assertThat(fee).isEqualTo(expectedFee);
}

설명:

  • @CsvSource는 입력/출력 쌍을 제공하며, 각 줄은 독립적인 테스트 케이스입니다.
  • 보고서는 각 케이스를 별도로 표시하므로 어느 케이스가 실패했는지 즉시 알 수 있습니다.
calculateShippingFee_variousOrderAmounts_returnsExpectedFee(BigDecimal, BigDecimal)
  ✓ [1] 0, 0
  ✓ [2] 100, 10
  ✗ [3] 1000, 100
  ✓ [4] -50, 0

6. 적절한 수준의 Mock 사용, 과도하게 사용하지 않기

외부 의존성(데이터베이스, HTTP 클라이언트, 다른 서비스)에는 mock을 사용하세요. 테스트 중인 객체 자체는 mock하면 안 됩니다.

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private PaymentService paymentService;

    @Test
    void processPayment_whenGatewayApproves_marksOrderAsPaid() {
        Order order = new Order("ORD-01", BigDecimal.valueOf(200));
        when(paymentGateway.charge(order.getAmount())).thenReturn(ApprovalResult.approved());

        PaymentResult result = paymentService.processPayment(order);

        assertThat(result.isSuccess()).isTrue();
        verify(paymentGateway).charge(BigDecimal.valueOf(200));
    }
}

설명:

  • @Mock은 PaymentGateway 대역을 생성합니다. 외부 API를 호출할 수 있는 실제 객체입니다.
  • @InjectMocks는 mock을 테스트 중인 PaymentService에 주입합니다.
  • verify는 charge가 정확한 매개변수로 호출되었는지 확인합니다. 반환값만 확인하는 것이 아닙니다.

주의: 한 테스트에 3-4개 이상의 의존성을 mock 해야 한다면, 그 클래스가 너무 많은 일을 하고 있다는 신호입니다. 테스트를 나누기 전에 클래스 자체를 작게 나누는 것을 고려하세요.

7. 커버리지(Coverage): 참고 지표일 뿐, 목표가 아님

높은 커버리지는 좋은 테스트와 같지 않습니다. 예를 들어, 다음 테스트는 divide 메서드에서 100% 라인 커버리지를 달성하지만 버그를 찾지 못합니다:

public int divide(int a, int b) {
    return a / b;
}
@Test
void divide_returnsQuotient() {
    Calculator calculator = new Calculator();

    int result = calculator.divide(10, 2);

    assertThat(result).isEqualTo(5);
}

이 테스트는 모든 코드 라인을 실행하지만(100% 커버리지) b = 0인 경우를 테스트하지 않습니다. 프로덕션에서는 이 부분에서 ArithmeticException이 발생합니다. 커버리지는 어느 라인을 실행했는지만 측정하고, 올바르게 검증했는지는 측정하지 않습니다.

7.1. 커버리지를 사용하여 맹점을 찾으세요. 아름다운 보고서를 위해 사용하지 마세요.

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
    </executions>
</plugin>

mvn test를 실행하면 JaCoCo가 target/site/jacoco/index.html에서 HTML 보고서를 생성합니다. 이 보고서를 사용하여 “어느 분기가 아직 테스트되지 않았는가”라는 질문에 답한 후, “그 분기가 중요한 로직을 포함하는가”를 스스로 묻습니다. 절대적인 % 수치를 쫓지 마세요.

Element           Missed Instructions  Cov.
Calculator        12 of 40             70%
  divide()        4 of 8               50%  ← b=0 케이스 미테스트

7.2. 경계 케이스(Edge Case) 테스트가 % 수치보다 중요함

위에서 놓친 케이스를 보충하세요:

@Test
void divide_whenDivisorIsZero_throwsArithmeticException() {
    Calculator calculator = new Calculator();

    assertThatThrownBy(() -> calculator.divide(10, 0))
            .isInstanceOf(ArithmeticException.class);
}

이 한 줄의 테스트는 일반적인 케이스를 반복하여 % 커버리지를 높이기 위해 작성한 5개의 테스트보다 훨씬 가치가 있습니다.

8. 테스트 자체 검토 체크리스트

Pull Request를 생성하기 전에 자신을 확인하세요:

  • 테스트 이름이 조건과 기대값을 명확히 설명하는가, 아니면 test1, testX 같은가?
  • 각 테스트가 정확히 하나의 동작과 하나의 실패 이유를 가지는가?
  • 경계 케이스가 있는가: null, empty, 0, 음수, 상한?
  • 오류 케이스(exception)를 테스트하는가, 아니면 happy path만 테스트하는가?
  • 커버리지가 낮은 부분이 어디인가 — 빠진 중요 로직인가, 아니면 테스트할 필요 없는 getter/setter인가?

9. 결론

좋은 Unit Test는 실패했을 때 디버깅 없이 문제가 정확히 어디에 있는지 알 수 있는 테스트입니다. method_condition_expected 패턴으로 이름을 지정하고, 각 테스트에서 하나의 동작만 하도록 AAA 구조를 명확하게 유지하며, 커버리지를 달성 목표가 아닌 맹점 찾기 도구로 사용하세요. 이 세 가지 간단한 원칙으로도 당신의 테스트 스위트 품질을 한 단계 높일 수 있습니다. 가장 어려운 부분은 assert를 작성하는 것이 아니라 프로덕션에서 코드가 마주칠 수 있는 경계 케이스를 생각하는 데 시간을 들이는 것입니다.

testingjunitjavabest-practicesspring-boot