테스트 코드는 이 기능이 어떻게 동작해야 하는지를 코드로 설명하는 문서다. 메서드 이름과 given-when-then 구조만 잘 작성해도 새로 합류한 팀원이 코드 흐름을 파악하는 데 도움이 된다.
기능을 수정했을 때 관련 테스트가 실패해야 한다. 실패하지 않는다면 그 테스트는 실제로 동작을 검증하고 있지 않은 것이다. 반대로 수정하지 않았는데 실패하는 테스트도 문제다.
테스트 A가 싱글턴 객체의 상태를 바꾸고 나서 테스트 B가 실행되면, B는 예상과 다른 상태에서 시작한다. A가 실행된 뒤에는 성공하고 B가 먼저 실행되면 실패하는 식으로, 테스트 순서에 따라 결과가 달라진다.
외부 상태에 의존하는 경우도 마찬가지다. DB에 데이터가 남아 있을 때만 통과하거나, LocalDateTime.now()처럼 실행 시점에 따라 결과가 달라지는 코드가 포함되면 동일한 코드에서도 실패가 발생한다.
이 두 가지를 포함해 좋은 테스트의 다섯 가지 원칙을 F.I.R.S.T로 정리한다.
| 약자 | 의미 |
|---|---|
| Fast | 빠르게 실행된다 |
| Independent | 테스트끼리 독립적이다 |
| Repeatable | 어느 환경에서도 같은 결과를 낸다 |
| Self-validating | 결과가 pass/fail로 명확하다 |
| Timely | 적절한 시점에 작성된다 |
JaCoCo 같은 도구로 커버리지를 측정할 수 있다. 100%를 목표로 잡으면 달성 가능하긴 하다. 하지만 util 클래스, getter, 단순 초기화 코드까지 커버하려다 보면 실질적인 검증보다 수치를 채우기 위한 테스트가 늘어난다.
중요한 건 수치가 아니라 무엇을 커버하는지다. 도메인 규칙과 비즈니스 로직은 100%를 목표로 하고, 인프라나 설정은 선택적으로, util이나 단순 getter는 제외하는 방식이 현실적이다.
커버리지를 볼 때 라인 커버리지와 브랜치 커버리지를 구분해야 한다. 라인 커버리지는 코드 줄이 실행됐는지만 본다. 브랜치 커버리지는 if/else의 양쪽 분기가 모두 통과됐는지를 본다.
예를 들어 장기 게임에서 졸의 이동 로직을 생각해보자.
public boolean canMove(Position from, Position to, boolean isInsidePalace) {
if (isInsidePalace) {
return canMoveDiagonally(from, to); // 궁성 내부 → 대각선 허용
}
return canMoveStraight(from, to); // 궁성 외부 → 직선만
}
라인 커버리지 100%는 이 메서드가 한 번이라도 호출됐으면 충족된다. 브랜치 커버리지 100%는 isInsidePalace가 true인 경우와 false인 경우를 모두 테스트해야 한다. 실질적인 검증은 브랜치 커버리지에서 나온다.
실제 결제 API를 호출하거나 외부 서비스와 통신하는 코드를 테스트할 때, 매번 실제 요청을 보낼 수는 없다. 이때 실제 객체 대신 사용하는 것이 테스트 더블이다.
미리 정해둔 값을 반환한다. 실제로 어떻게 호출됐는지는 관심 없고, 특정 상황에서 어떤 값이 반환될 때 코드가 올바르게 동작하는지를 검증할 때 쓴다. 상태 검증에 초점을 둔다.
when(paymentService.charge(any())).thenReturn(new PaymentResult(true));
호출 자체를 검증한다. 메서드가 몇 번 호출됐는지, 어떤 인자로 호출됐는지를 확인할 때 쓴다. Stub이 결과값에 집중한다면 Mock은 호출 행위에 집중한다.
verify(paymentService, times(1)).charge(any());
실제 객체를 감싸서 동작한다. 기본적으로 실제 로직이 실행되고, 특정 메서드만 Stub으로 대체할 수 있다. 외부 API 호출 부분만 막고 나머지 로직은 실제로 실행해야 할 때 유용하다.
@Spy PaymentService paymentService = new PaymentService();
doReturn(true).when(paymentService).callExternalApi(); // 이 부분만 막음