본문으로 건너뛰기
고급약 40분

뮤테이션 테스트 — 커버리지가 답하지 못하는 질문, 이 테스트는 결함을 잡는가

커버리지는 코드가 실행됐는지만 센다. 뮤테이션 테스트는 코드에 의도적으로 결함을 심고 테스트가 실패하는지 확인해 테스트의 검출력을 직접 측정한다. PIT와 Stryker를 실제로 돌리고, 뮤테이션 점수를 읽고, 생존 뮤턴트를 어설션 부족·케이스 부족·등가 뮤턴트·죽은 코드로 분류하며, 느린 실행 시간을 변경분 한정 실행으로 실무에서 감당 가능하게 만드는 법을 익힌다.

  • #뮤테이션테스트
  • #PIT
  • #Stryker
  • #뮤테이션점수
  • #테스트품질

개념

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

레슨 1의 마지막에서 남겨 둔 문제가 있다. 아래 테스트는 커버리지를 100% 만든다.

@Test
void 배송비를_계산한다() {
    calculator.shippingFee(10_000, false, "SEOUL");   // 호출만 한다
}

실행됐으니 커버리지는 올라가고, 로직을 아무리 망가뜨려도 이 테스트는 초록색이다. 극단적인 예처럼 보이지만, 어설션이 약한 테스트 는 실무에 흔하다. assertNotNull(result), assertTrue(list.size() > 0), 상태 변경을 확인하지 않고 예외만 안 나면 통과시키는 테스트가 전부 같은 부류다.

뮤테이션 테스트는 이 질문에 정면으로 답한다. "코드를 조금 망가뜨렸을 때 테스트가 빨갛게 변하는가." 변하지 않으면 그 테스트는 그 부분을 지키고 있지 않은 것이다.

용어

용어뜻
뮤턴트(mutant)원본 코드에 작은 변경(뮤테이션 연산자)을 한 번 적용한 변종
죽인다(killed)그 뮤턴트를 실행했을 때 테스트가 실패 했다. 테스트가 결함을 잡았다는 뜻
생존(survived)뮤턴트를 실행했는데 모든 테스트가 통과했다. 테스트가 못 잡았다
커버되지 않음(no coverage)그 뮤턴트가 있는 줄을 어떤 테스트도 실행하지 않았다
시간 초과(timeout)뮤턴트가 무한 루프를 만들었다. 보통 killed로 센다
등가 뮤턴트(equivalent mutant)코드는 바뀌었지만 동작이 원본과 완전히 동일 하다. 원리상 죽일 수 없다
뮤테이션 점수killed ÷ (전체 뮤턴트 - 등가 뮤턴트). 실무에서는 등가 판정을 못 하므로 killed ÷ 전체로 본다

뮤테이션 연산자 — 무엇을 어떻게 망가뜨리는가

도구가 심는 결함은 무작위가 아니라 사람이 실제로 자주 내는 실수 를 흉내 낸다.

연산자원본뮤턴트노리는 테스트 약점
조건 경계a >= ba > b경계값 케이스 누락
조건 부정a == ba != b참·거짓 한쪽만 테스트
수학 연산a + ba - b계산 결과를 검증하지 않음
증감i++i--반복 결과를 검증하지 않음
반환값 치환return x;return 0; / return null;반환값을 안 보거나 형식만 확인
호출 제거logger.audit(e);(삭제)부수효과를 검증하지 않음
조건을 상수로if (cond)if (true)분기 한쪽만 확인
빈 컬렉션/기본값return list;return emptyList();크기만 보고 내용을 안 봄

"호출 제거"가 특히 날카롭다. 로그 적재·이벤트 발행·캐시 무효화처럼 결과값에 안 나타나는 부수효과 를 검증하는 테스트가 있는지를 정확히 묻기 때문이다.

PIT로 JVM 코드 돌리기

<!-- pom.xml -->
<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>${pitest.version}</version>
  <dependencies>
    <dependency>
      <groupId>org.pitest</groupId>
      <artifactId>pitest-junit5-plugin</artifactId>
      <version>${pitest.junit5.version}</version>
    </dependency>
  </dependencies>
  <configuration>
    <targetClasses>
      <param>com.example.payment.*</param>
    </targetClasses>
    <targetTests>
      <param>com.example.payment.*Test</param>
    </targetTests>
    <mutators>
      <mutator>DEFAULTS</mutator>
      <mutator>REMOVE_CONDITIONALS</mutator>
    </mutators>
    <mutationThreshold>70</mutationThreshold>
    <coverageThreshold>85</coverageThreshold>
    <timeoutConstant>4000</timeoutConstant>
    <outputFormats>
      <param>HTML</param>
      <param>XML</param>
    </outputFormats>
  </configuration>
</plugin>
mvn test-compile org.pitest:pitest-maven:mutationCoverage
open target/pit-reports/index.html

리포트는 클래스별로 두 숫자를 보여 준다.

Package                       Line Coverage   Mutation Coverage   Test Strength
com.example.payment              91% (142/156)     43% (34/79)        47%
com.example.catalog              68% (88/129)      61% (55/90)        89%

이 표의 해석이 이 레슨의 핵심이다.

  • payment: 라인 91%, 뮤테이션 43% — 코드는 실행되지만 검증되지 않는다. 어설션 문제
  • catalog: 라인 68%, 뮤테이션 61%, test strength 89% — 테스트가 닿는 곳은 잘 검증한다. 케이스 부족 문제

Test Strength 는 "커버된 뮤턴트 중 죽인 비율"이다. 커버리지 부족과 검증력 부족을 분리해 주므로, 두 문제를 섞어서 논쟁하지 않게 해 준다. 라인 커버리지가 낮은 팀에 "테스트를 더 쓰세요"라고 하는 것과, 라인 커버리지가 높은 팀에 "어설션을 붙이세요"라고 하는 것은 전혀 다른 처방이다.

Stryker로 JS/TS 코드 돌리기

npm init stryker
// stryker.config.json
{
  "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
  "packageManager": "npm",
  "testRunner": "vitest",
  "reporters": ["html", "clear-text", "progress"],
  "coverageAnalysis": "perTest",
  "mutate": ["src/domain/**/*.ts", "!src/**/*.spec.ts"],
  "thresholds": { "high": 80, "low": 60, "break": 55 },
  "timeoutMS": 10000,
  "concurrency": 4
}
npx stryker run

coverageAnalysis: "perTest" 가 실행 시간을 결정적으로 줄인다. 어떤 테스트가 어떤 코드를 실행하는지 먼저 측정해 두고, 각 뮤턴트에 대해 그 줄을 지나가는 테스트만 실행 한다. 이 설정이 없으면 뮤턴트 하나마다 전체 스위트를 돌리므로 시간이 수십 배로 늘어난다.

생존 뮤턴트를 읽는 네 갈래

리포트에서 생존 뮤턴트를 하나씩 열면 반드시 아래 넷 중 하나다. 분류하는 습관이 뮤테이션 테스트를 쓸모 있게 만든다.

생존 뮤턴트 하나
   │
   ├─ 이 줄을 실행하는 테스트가 있는가?
   │     아니오 → ① 케이스 부족: 테스트 케이스를 추가한다
   │
   ├─ 예. 그 테스트는 이 줄의 결과를 확인하는가?
   │     아니오 → ② 어설션 부족: 어설션을 강화한다 (가장 흔하다)
   │
   ├─ 예. 그런데 바뀐 동작이 원본과 정말 같은가?
   │     예   → ③ 등가 뮤턴트: 억제 목록에 넣고 사유를 기록한다
   │
   └─ 그 코드가 제품 동작에 아무 영향을 주지 않는가?
         예   → ④ 죽은 코드: 코드를 지운다 (가장 좋은 결말)

②의 예를 보자.

// 원본
public int fee(int total) {
    return total >= 50_000 ? 0 : 3_000;
}
 
// 생존한 뮤턴트: >= 를 > 로 바꿨는데 테스트가 통과했다
// 원인이 된 테스트 — 경계값 50,000이 없다
@Test void 오만원_넘으면_무료() { assertEquals(0, fee(60_000)); }
@Test void 오만원_미만은_유료() { assertEquals(3_000, fee(10_000)); }

뮤턴트가 모듈 3에서 배운 경계값 케이스가 빠졌다는 사실을 정확히 짚어 준 것 이다. assertEquals(0, fee(50_000)) 을 추가하면 뮤턴트가 죽는다. 경계값 분석을 사람이 빠뜨렸는지 도구가 기계적으로 검사해 주는 셈이다.

③ 등가 뮤턴트의 전형적인 예는 이렇다.

for (int i = 0; i < list.size(); i++) { ... }
// 뮤턴트: i < list.size()  →  i != list.size()
// 반복 조건상 동작이 동일하다. 죽일 수 없다

등가 뮤턴트는 이론적으로 판정이 결정 불가능한 문제다. 실무에서는 뮤테이션 점수 100%를 목표로 삼지 않는 것 으로 대응한다. 생존 뮤턴트를 하나하나 없애려다 등가 뮤턴트 앞에서 시간을 태우는 것이 이 기법을 포기하게 만드는 가장 흔한 경로다.

느리다 — 실무에서 감당하는 법

뮤테이션 테스트는 본질적으로 "뮤턴트 수 × 테스트 시간"이다. 전체 저장소에 매 PR마다 돌리는 것은 대개 불가능하다. 실무 전략은 네 가지다.

전략방법효과
대상 축소도메인·계산 로직 패키지만 mutate 대상으로가장 효과가 크다. 컨트롤러·DTO는 제외
변경분 한정PIT: SCM 모드 / Stryker: --since=origin/mainPR 시간 안에 들어온다
증분 분석PIT historyInputLocation/historyOutputLocation직전 결과와 비교해 안 바뀐 뮤턴트는 건너뛴다
주기 실행야간·주간 스케줄로 전체 실행, 추세만 관리게이트가 아니라 지표로
# Stryker: 기본 브랜치 이후 바뀐 파일만
npx stryker run --since=origin/main
 
# PIT(Maven): 변경분만 + 이력 기반 증분
mvn org.pitest:pitest-maven:scmMutationCoverage \
    -Dinclude=ADDED,MODIFIED \
    -DhistoryInputLocation=target/pit-history \
    -DhistoryOutputLocation=target/pit-history

도입 순서에 대한 권고는 분명하다. 처음부터 게이트로 걸지 않는다. 고위험 패키지 하나에 리포트만 생성해 팀에 보여 주고, 생존 뮤턴트 상위 열 건을 함께 읽는 회의를 한 번 한다. 대부분의 팀이 그 자리에서 어설션 문화를 바꾼다. 게이트는 그다음이고, 걸더라도 break 임계값은 현재 점수보다 낮게 시작해 하락만 막는다.

커버리지 게이트와의 관계

커버리지뮤테이션 점수
재는 것코드가 실행됐는가테스트가 결함을 잡는가
속이는 법어설션 없이 호출만속이기 어렵다
비용낮음(테스트 1회 실행)높음(뮤턴트마다 실행)
게이트 적합성변경분 기준으로 매 PR고위험 모듈·주기 실행

둘은 대체재가 아니라 보완재다. 커버리지로 "안 본 곳"을 찾고, 뮤테이션으로 "본 척한 곳"을 찾는다.

실무에서 왜 필요한가

자동화 테스트가 수백 개 있는데 운영 결함이 줄지 않는 팀이 있다. 원인은 대개 테스트 수가 아니라 테스트의 검출력이고, 그것을 증명할 숫자가 없어서 논쟁이 반복된다. 뮤테이션 점수는 그 논쟁을 끝낸다. "결제 모듈은 라인 커버리지 91%인데 뮤테이션 점수 43%입니다. 79개 뮤턴트 중 45개가 살아남았고, 그중 31개가 어설션이 없어서 살았습니다"는 반박하기 어려운 문장이다.

리팩터링 안전망을 점검할 때도 쓴다. 대규모 리팩터링 전에 대상 모듈의 뮤테이션 점수를 재 보면, 그 테스트 스위트를 믿고 구조를 바꿔도 되는지 를 사전에 알 수 있다. 점수가 낮다면 리팩터링 전에 테스트부터 보강하는 것이 순서다.

채용시장에서의 위치

뮤테이션 테스트를 실제로 운영해 본 사람은 국내에 많지 않다. 그래서 면접에서 이 주제를 꺼낼 수 있으면 차별점이 된다. 다만 "돌려 봤습니다"로는 약하다. 점수를 어떻게 해석했고, 생존 뮤턴트를 어떤 기준으로 분류했고, 실행 시간을 어떻게 감당 가능하게 만들었는지 까지가 한 세트다. 특히 "전체에 게이트로 걸지 않고 변경분 한정으로 시작했다"는 판단이 실무 감각을 보여 준다.

실습 과제

과제 1 — 약한 테스트의 정체 드러내기 (25분)

아래 코드와 테스트로 작은 프로젝트를 만들고 뮤테이션 테스트를 돌린다(언어는 자유, PIT 또는 Stryker).

export function grade(score: number, isRetake: boolean): string {
  if (score < 0 || score > 100) throw new RangeError('범위를 벗어난 점수')
  const capped = isRetake ? Math.min(score, 80) : score
  if (capped >= 90) return 'A'
  if (capped >= 80) return 'B'
  if (capped >= 70) return 'C'
  return 'F'
}
describe('grade', () => {
  it('높은 점수는 A', () => { expect(grade(95, false)).toBeTruthy() })
  it('재수강은 상한이 있다', () => { grade(95, true) })
  it('범위를 벗어나면 예외', () => { expect(() => grade(-1, false)).toThrow() })
})
  1. 커버리지를 먼저 측정한다. 몇 %인가
  2. 뮤테이션 테스트를 돌린다. 점수가 몇 인가
  3. 생존 뮤턴트를 네 갈래로 분류한 표를 만든다
  4. 뮤턴트를 하나씩 죽이는 테스트를 추가하며 점수 변화를 기록한다
  5. 죽일 수 없는 뮤턴트 가 있으면 왜 등가인지 설명한다

과제 2 — 우리 코드의 검출력 재기 (20분)

실제 업무 저장소에서 계산·정책 로직이 있는 패키지 하나를 골라 뮤테이션 테스트를 돌린다. 대상을 좁히는 설정(targetClasses / mutate)을 반드시 쓴다. 결과를 아래 형식으로 정리한다.

대상: ____________       라인 커버리지: ___%   뮤테이션 점수: ___%   Test Strength: ___%
생존 뮤턴트 상위 5건
1) 파일:줄  연산자  → 분류(어설션/케이스/등가/죽은코드)  → 조치
...

과제 3 — 실행 시간 길들이기 (15분)

과제 2의 실행 시간을 재고, 다음 순서로 줄여 각 단계의 시간을 기록한다.

  1. 기본 실행 시간
  2. 대상 패키지를 절반으로 좁혔을 때
  3. 변경분 한정(--since / SCM 모드)으로 바꿨을 때
  4. 증분 분석 이력 파일을 쓸 때

그다음 "매 PR에서 돌릴 것인가, 야간에 돌릴 것인가"를 시간 근거로 결정하고 한 문장으로 적는다.

자가 체크리스트

  • 커버리지와 뮤테이션 점수가 각각 무엇을 재는지 한 문장씩으로 구분할 수 있다
  • killed·survived·no coverage·timeout·등가 뮤턴트를 구분할 수 있다
  • 주요 뮤테이션 연산자 다섯 가지와 각각이 노리는 테스트 약점을 말할 수 있다
  • PIT 또는 Stryker를 설정 파일까지 직접 작성해 실행할 수 있다
  • 라인 커버리지·뮤테이션 점수·Test Strength 세 숫자를 함께 읽고 처방을 나눌 수 있다
  • 생존 뮤턴트를 어설션 부족·케이스 부족·등가·죽은 코드로 분류할 수 있다
  • 등가 뮤턴트 때문에 100%를 목표로 삼으면 안 되는 이유를 설명할 수 있다
  • 대상 축소·변경분 한정·증분 분석으로 실행 시간을 줄일 수 있다
  • 뮤테이션 테스트를 게이트로 걸 때와 지표로만 쓸 때를 구분할 수 있다

흔한 실수

  • 뮤테이션 점수 100%를 목표로 잡는다. 등가 뮤턴트 때문에 도달할 수 없다. 추세와 고위험 구간에 집중한다.
  • 전체 저장소에 매 PR마다 돌린다. CI가 몇 시간이 되고 결국 꺼진다. 대상 축소와 변경분 한정이 먼저다.
  • 생존 뮤턴트를 분류 없이 "테스트 추가"로만 대응한다. 어설션 부족인데 케이스를 늘리면 약한 테스트만 더 생긴다.
  • coverageAnalysis 설정을 켜지 않는다. 뮤턴트마다 전체 스위트가 돌아 실행 시간이 폭발한다.
  • 도입하자마자 게이트로 건다. 팀이 이유를 이해하기 전에 막히면 기법 자체가 폐기된다. 리포트를 함께 읽는 회의가 먼저다.
  • 커버리지를 대체한다고 생각한다. 뮤테이션 테스트는 실행되지 않은 코드에 대해서는 아무것도 말해 주지 않는다. 둘을 함께 본다.

참고 자료

  • PIT Mutation Testing — 뮤테이터 목록, Maven·Gradle 설정, 증분 분석과 SCM 모드(버전별 옵션은 원문 확인)
  • Stryker Mutator — JS/TS·C#·Scala용 뮤테이션 도구와 설정 스키마
  • Stryker — Mutators — 지원 뮤테이션 연산자 목록
  • Mutation Testing Elements — 리포트 스키마 — 도구 간 공통 리포트 형식
  • 레슨 s3-m53-l01 — 커버리지 도구 실습
  • 레슨 s3-m10-l01 — 단위 테스트란 무엇인가
  • 레슨 s1-m03-l01 — 동등 분할과 경계값 분석

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

JVM — 자바 가상 머신
자바로 만든 프로그램을 돌려 주는 바탕 프로그램.
PR — 풀 리퀘스트
"내가 고친 코드를 본체에 합쳐 달라"고 올리는 요청. 합치기 전에 사람들이 보고 의견을 단다.
CI — 지속적 통합
코드를 올릴 때마다 자동으로 합쳐서 빌드하고 시험까지 돌리는 방식.

용어집 전체 보기

이 레슨의 실습

실습은 별도 저장소에서 진행합니다. 새 탭으로 열립니다.