← all posts
DEV 2026.05.02 · 14 min read Intermediate

테스트는 코드를 실행했다는 증거가 아니다

커버리지 100%가 버그를 잡지 못하는 이유부터 뮤테이션 테스팅, 속성 기반 테스트, 아키텍처 규칙 자동화, TDD 설계 피드백, 레거시 코드 공략까지 — 검증의 철학을 추적한다.


커버리지 100%를 달성한 테스트 스위트가 버그를 그냥 통과시킨다. assertThat 없이 메서드를 호출하기만 한 테스트, 경계값 바로 옆을 건드리지 않는 테스트 — 이것들은 코드를 실행했다는 증거는 있지만 코드를 검증했다는 증거는 없다. 그렇다면 테스트가 실제로 무언가를 검증한다는 것은 어떻게 알 수 있는가?

커버리지가 답하지 못하는 질문

커버리지 도구가 측정하는 것은 단순하다 — 이 라인이 실행됐는가. 반환값이 올바른지, 경계 조건이 정확한지는 묻지 않는다.

// 커버리지 100% — 하지만 아무것도 검증하지 않는다
@Test
void 할인_계산() {
    service.calculateDiscount(vipUser, 20_000); // 반환값 미확인
}

뮤테이션 테스팅은 이 질문에 답한다. 프로덕션 코드에 의도적으로 작은 오류(뮤턴트)를 심고, 테스트가 그것을 발견하는지 측정한다.

원본: if (amount >= 50_000)
뮤턴트: if (amount > 50_000)   → Survived ❌ (경계값 테스트 없음)
뮤턴트: if (amount <= 50_000)  → Killed ✅

뮤턴트가 Survived된다는 것은 테스트가 그 오류를 알아채지 못한다는 뜻이다. 경계값 직전·정확히·직후를 모두 검증하는 테스트가 있어야 >= 50_000> 50_000을 구분할 수 있다.

PITest를 사용하면 이 측정이 자동화된다. 목표 점수는 핵심 비즈니스 로직 기준 80~85%. 100%는 동등 뮤턴트(equivalent mutant) — 동작이 바뀌지 않는 코드 변경 — 때문에 달성이 불가능한 경우가 많고, 목표로 삼을 필요도 없다.

트레이드오프

PITest는 각 뮤턴트마다 테스트 스위트 전체를 실행하므로 느리다. targetClasses로 핵심 비즈니스 로직 패키지만 대상으로 삼고, CI에서는 PR 단위 또는 nightly 빌드로 분리하는 것이 현실적이다.

경계를 직접 생각하지 않아도 되는 방법

예제 기반 테스트의 한계는 명확하다 — 우리가 생각해낸 케이스만 검증한다. 100_00010_000으로 배송비를 검증해도 50_001에서 무슨 일이 일어나는지는 알 수 없다.

속성 기반 테스트(jqwik)는 “어떤 입력에서도 이 성질이 성립한다”를 표현한다. 도구가 수백 개의 무작위 입력을 생성해서 성질이 깨지는 지점을 찾는다.

// "할인 후 금액은 원금을 초과하지 않는다"
@Property
void 할인_후_금액은_원금보다_크지_않다(
        @ForAll @IntRange(min = 1_000, max = 1_000_000) int price,
        @ForAll @IntRange(min = 0, max = 100) int rate) {

    assertThat(calculator.applyDiscount(price, rate))
        .isLessThanOrEqualTo(price)
        .isGreaterThanOrEqualTo(0);
}

테스트가 실패하면 jqwik은 Shrinking으로 실패를 재현하는 가장 작은 입력을 자동으로 찾아준다. weight=23_847에서 실패를 발견했다면 weight=5_001까지 좁혀준다 — 경계값 처리 버그가 어디 있는지 정확히 가리킨다.

두 방식은 대체 관계가 아니다. 예제 기반은 핵심 시나리오를, 속성 기반은 수학적 성질과 알고리즘의 불변식을 검증하는 데 각자 강점이 있다.

아키텍처 규칙은 코드로 강제해야 한다

“도메인 레이어는 인프라를 의존하면 안 된다”는 규칙이 문서에만 존재하면 지켜지지 않는다. 개발자 A가 엔티티에서 JpaRepository를 직접 참조하고, 빌드는 통과하고, 코드 리뷰에서 놓치면 그걸로 끝이다.

ArchUnit은 이 규칙을 JUnit 테스트로 표현해서 빌드 시점에 강제한다.

@ArchTest
static final ArchRule 도메인은_인프라를_모른다 =
    noClasses()
        .that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
            .resideInAnyPackage("..adapter..", "org.springframework.data..");

레거시 코드베이스에 처음 도입할 때는 기존 위반을 @ArchIgnore("레거시 — 이슈 #N")으로 일시 제외하고 CI에 포함한다. 새 코드에서의 위반은 즉시 빌드 실패로 차단하고, @ArchIgnore된 항목은 이슈로 추적해서 점진적으로 해소한다.

TDD가 설계를 이끄는 방식

Red → Green → Refactor는 각 단계의 목적이 다르다. Green 단계에서 설계를 개선하려 하거나, Refactor 단계에서 새 기능을 추가하면 두 가지 목표가 충돌해서 둘 다 실패한다.

더 중요한 것은 TDD가 설계 피드백 루프라는 점이다. 테스트를 먼저 쓰다가 막히는 순간이 있다:

"이 클래스를 테스트하려면 DB가 필요하다"
  → 비즈니스 로직이 인프라와 분리되지 않음

"이 클래스를 생성하려면 의존성이 10개 필요하다"
  → SRP 위반, 클래스가 너무 많은 일을 함

"private 메서드를 직접 테스트하고 싶다"
  → 별도 클래스로 추출하라는 신호

이 막힘은 버그가 아니라 설계 문제를 가리키는 신호다. 테스트하기 어렵다는 것과 설계가 나쁘다는 것은 같은 말이다. 느슨한 결합, 생성자 주입, 단일 책임 — 테스트 가능성이 높은 코드의 특징은 좋은 OOP 설계의 특징과 일치한다.

레거시 코드를 공략하는 순서

테스트 없이 리팩터링하면 위험하고, 리팩터링 없이 테스트를 추가하기도 어렵다. 이 딜레마를 푸는 순서가 있다.

Characterization Test로 현재 동작을 먼저 고정한다. “올바른” 동작이 아니라 지금 실제로 어떻게 동작하는지를 기록한다. 버그도 일단 고정한다. 이것이 변경의 안전망이 된다.

그다음 Seam — 코드를 크게 바꾸지 않고 의존성을 교체할 수 있는 지점 — 을 만든다. protected factory 메서드로 서브클래싱을 허용하거나, LocalDateTime.now()를 파라미터로 추출하거나, 구체 클래스를 인터페이스로 추상화한다. 최소한의 변경으로 테스트 진입점을 만드는 것이 목표다.

Seam이 생기면 단위 테스트를 추가하고, 그제야 안전하게 리팩터링한다.

“처음부터 다시 짜는 것(Big Rewrite)“은 레거시 코드에 암묵적으로 쌓인 비즈니스 지식과 엣지케이스를 새 코드에서 재현하기 어렵기 때문에 위험하다. 점진적 접근이 안전하다.

정리

  • 커버리지는 실행을, 뮤테이션 점수는 검증을 측정한다. 두 지표는 다른 질문에 답한다.
  • 속성 기반 테스트는 경계를 직접 생각하지 않아도 도구가 반례를 탐색한다. 예제 기반과 보완적으로 쓴다.
  • 아키텍처 규칙은 ArchUnit으로 빌드에 포함시켜야 실제로 지켜진다. 문서와 코드 리뷰만으로는 부족하다.
  • “테스트하기 어렵다”는 신호를 설계 문제로 인식하면 TDD는 설계 도구가 된다.
  • 레거시 코드는 Characterization Test → Seam → 단위 테스트 → 리팩터링 순서로 접근한다.

테스트가 통과한다는 것과 테스트가 무언가를 검증한다는 것은 다르다. 그 간격을 좁히는 것이 이 시리즈 전체의 주제다.