개념
커버리지가 답하지 못하는 질문
레슨 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 >= b | a > b | 경계값 케이스 누락 |
| 조건 부정 | a == b | a != b | 참·거짓 한쪽만 테스트 |
| 수학 연산 | a + b | a - 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 runcoverageAnalysis: "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/main | PR 시간 안에 들어온다 |
| 증분 분석 | 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개가 어설션이 없어서 살았습니다"는 반박하기 어려운 문장이다.
리팩터링 안전망을 점검할 때도 쓴다. 대규모 리팩터링 전에 대상 모듈의 뮤테이션 점수를 재 보면, 그 테스트 스위트를 믿고 구조를 바꿔도 되는지 를 사전에 알 수 있다. 점수가 낮다면 리팩터링 전에 테스트부터 보강하는 것이 순서다.
채용시장에서의 위치
뮤테이션 테스트를 실제로 운영해 본 사람은 국내에 많지 않다. 그래서 면접에서 이 주제를 꺼낼 수 있으면 차별점이 된다. 다만 "돌려 봤습니다"로는 약하다. 점수를 어떻게 해석했고, 생존 뮤턴트를 어떤 기준으로 분류했고, 실행 시간을 어떻게 감당 가능하게 만들었는지 까지가 한 세트다. 특히 "전체에 게이트로 걸지 않고 변경분 한정으로 시작했다"는 판단이 실무 감각을 보여 준다.