← all posts
DEV 2026.05.02 · 13 min read Intermediate

좋은 단위 테스트는 어떻게 쓰는가

단일 동작 검증 원칙부터 경계값 분석, 파라미터화, 픽스처 관리, 의미 있는 단언까지 — 테스트를 설계하는 다섯 가지 핵심 원칙을 추적한다.


단위 테스트가 있는데도 버그가 발견된다. 테스트가 실패해도 무엇이 잘못됐는지 알 수 없다. 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는 모든 테스트에서 필요한 인프라 초기화에만 쓰고, 테스트 데이터는 테스트 안에서 직접 만들어라.
  • 실패 메시지가 코드를 열지 않아도 원인을 알려줄 때 비로소 단언이 제 역할을 한다.