좋은 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를 작성하는 것이 아니라 프로덕션에서 코드가 마주칠 수 있는 경계 케이스를 생각하는 데 시간을 들이는 것입니다.