좋은 단위 테스트는 어떻게 쓰는가
단일 동작 검증 원칙부터 경계값 분석, 파라미터화, 픽스처 관리, 의미 있는 단언까지 — 테스트를 설계하는 다섯 가지 핵심 원칙을 추적한다.
- 01 좋은 단위 테스트란 무엇인가
- 02 좋은 단위 테스트는 어떻게 쓰는가
- 03 Mock은 하나가 아니다 — Test Double의 다섯 얼굴
- 04 테스트하기 어렵다는 느낌은 설계 냄새다
- 05 단위 테스트가 통과해도 시스템이 망가지는 이유
- 06 테스트 코드가 프로덕션을 오염시키는 일곱 가지 방법
- 07 테스트는 코드를 실행했다는 증거가 아니다
단위 테스트가 있는데도 버그가 발견된다. 테스트가 실패해도 무엇이 잘못됐는지 알 수 없다. Arrange 코드가 너무 길어서 테스트의 핵심이 묻힌다. 이 문제들은 테스트의 양이 부족해서가 아니라, 테스트를 설계하는 방식이 잘못됐기 때문이다. 좋은 단위 테스트를 만드는 원칙은 무엇인가?
하나의 테스트, 하나의 동작
“단일 단언 원칙”을 assertThat을 한 줄만 쓰라는 뜻으로 오해하면 안 된다. 핵심은 하나의 테스트가 하나의 동작(behavior)만 검증해야 한다는 것이다.
테스트 이름 하나로 모든 단언을 설명할 수 있으면 단일 동작을 검증하는 것이다. 설명에 “그리고”, “또한”이 필요하다면 두 개의 테스트로 분리해야 한다.
// ❌ 하나의 테스트에 세 가지 동작이 섞여 있다
@Test
void 주문_생성_테스트() {
Order order = orderService.place(cart, vipUser);
assertThat(order.totalPrice()).isEqualTo(18_000); // 동작 1: 할인 적용
assertThat(order.status()).isEqualTo(PENDING); // 동작 2: 초기 상태
assertThat(emailSender.sentCount()).isEqualTo(1); // 동작 3: 알림 발송
}
// ✅ 동작 하나당 테스트 하나
@Test void VIP_회원_주문_시_10퍼센트_할인이_적용된다() { ... }
@Test void 주문_생성_직후_상태는_PENDING이다() { ... }
@Test void 주문_생성_시_확인_이메일이_발송된다() { ... }
세 번째 테스트가 실패하면 이름만 보고 “알림 발송”에 문제가 있음을 즉시 안다. 같은 동작의 결과를 여러 필드에 걸쳐 검증해야 할 때는 SoftAssertions로 모든 실패를 한 번에 확인할 수 있다.
테스트 데이터는 의도를 드러내야 한다
Arrange 코드가 길어지면 테스트의 핵심이 묻힌다. 미성년자 나이 제한을 검증하는 테스트에서 User 객체를 직접 생성할 때 주소, 전화번호, 생년월일이 모두 뒤섞이면, 테스트와 관련 있는 “생년월일”이 다른 정보에 묻혀 버린다.
Test Data Builder는 이 문제를 해결한다. 관련 없는 세부사항을 기본값으로 숨기고, 테스트와 관련 있는 값만 드러낸다.
// ✅ 핵심이 첫 줄에 명확히 보인다
@Test
void 미성년자는_성인_상품을_주문할_수_없다() {
User minor = aUser()
.withBirthDate(LocalDate.of(2010, 5, 15)) // ← 이것이 핵심
.build();
Item adultItem = anItem().withCategory(Category.ADULT).build();
assertThatThrownBy(() -> orderService.place(aCart().withUser(minor).withItem(adultItem).build(), minor))
.isInstanceOf(AgeRestrictionException.class);
}
빌더의 기본값은 “유효하지만 이 테스트와 관련 없는 값”이어야 한다. 기본값 때문에 테스트가 실패해서는 안 된다. 빌더는 src/test/java/support/builder/에 배치하고 프로덕션 코드와 절대 섞지 않는다.
버그는 경계에서 태어난다
정상 케이스만 테스트하는 팀이 놓치는 것은 항상 경계다.
public boolean isAdult(int age) {
return age > 18; // 18세는 성인인가, 아닌가?
}
isAdult(30)과 isAdult(15) 두 테스트만으로는 > 와 >= 중 어느 것이 맞는지 알 수 없다. 경계값 분석(BVA)은 입력 도메인을 동등 구역으로 나누고 각 경계에서 세 가지를 테스트한다 — 경계-1, 경계, 경계+1.
- 숫자 범위: 최솟값-1, 최솟값, 최댓값, 최댓값+1
- 컬렉션: 0개, 1개, 최대개, 최대+1개
- 문자열:
null,""," ", 최대 길이, 최대+1 길이 - 날짜/시간: 기준 시각 -1초, 정각, +1초
LocalDate.now()에 직접 의존하는 코드는 Clock을 주입받도록 설계를 바꾸면 경계값 테스트가 가능해진다.
같은 구역의 경계값 여러 개는 @ParameterizedTest로 자연스럽게 묶인다. 경계값 테스트를 케이스마다 메서드로 만드는 대신 @ValueSource(ints = {0, -1, 101})처럼 선언하면 유지보수 비용이 크게 줄어든다.
파라미터화와 픽스처 관리
같은 검증 로직에 입력만 다른 케이스가 여럿이라면 복사-붙여넣기 대신 파라미터화가 답이다. 소스 타입 선택 기준은 단순하다.
@ValueSource: 단일 타입 단순 값 목록@CsvSource: 입력과 기댓값 쌍, 원시 타입@MethodSource: 복잡한 객체, Test Data Builder 조합
파라미터화 테스트에서 name 속성은 필수다. @ParameterizedTest(name = "{0}등급 {1}원 → 기댓값 {2}원")처럼 설정해야 케이스 실패 시 “어떤 입력으로 실패했는지”를 바로 알 수 있다.
픽스처 관리에서 흔한 실수는 @BeforeEach를 “반복 줄이는 도구”로 오해하는 것이다. @BeforeEach에 넣어야 하는 것은 모든 테스트에서 공통으로 필요한 인프라 초기화(Repository, Service 객체)뿐이다. 일부 테스트에서만 필요한 테스트 데이터를 @BeforeEach에 올리면 “이 필드가 어떤 테스트에서 쓰이는가?”를 추적해야 하는 숨겨진 결합이 생긴다. 같은 컨텍스트를 공유하는 테스트들은 @Nested로 묶어서 픽스처 범위를 명확히 한다.
실패 메시지가 디버깅 시간을 결정한다
좋은 단언의 기준은 하나다 — 실패 메시지만 보고 코드를 열지 않아도 “무엇이 왜 틀렸는지” 알 수 있어야 한다.
assertTrue(order.totalPrice() == 18_000)이 실패하면 "expected: true, but was: false"만 보인다. assertThat(order.totalPrice()).isEqualTo(18_000)이 실패하면 "expected: 18000, but was: 20000"이 보인다. 두 번째가 디버깅 시간을 10분에서 10초로 줄인다.
AssertJ의 핵심 기법 몇 가지:
.as("설명")— 실패 메시지에 맥락 추가.extracting()— 컬렉션 필드 추출 후 검증.usingRecursiveComparison().ignoringFields("id", "createdAt")—equals없이도 깊은 비교.hasMessage()— 예외 메시지까지 검증
같은 검증 패턴이 세 곳 이상에서 반복된다면 AbstractAssert를 상속한 커스텀 단언으로 추출할 때다. assertThat(order).isCompleted().hasTotalPrice(18_000) 처럼 도메인 언어로 단언을 표현하면 테스트 의도가 즉시 드러난다.
정리
- 테스트 이름으로 모든 단언을 설명할 수 없다면 테스트를 분리하라.
- Arrange 코드가 테스트 의도를 묻기 시작할 때 Test Data Builder를 도입하라.
- 버그는 경계에서 발생한다 —
>와>=를 구분하는 것은 경계값 테스트뿐이다. @BeforeEach는 모든 테스트에서 필요한 인프라 초기화에만 쓰고, 테스트 데이터는 테스트 안에서 직접 만들어라.- 실패 메시지가 코드를 열지 않아도 원인을 알려줄 때 비로소 단언이 제 역할을 한다.