뮤테이션 테스트 스위트가 4시간이나 걸린다면, 축하한다. 모두가 이미 의심하던 사실을 증명한 셈이다. 당신의 테스트에는 구멍이 있다.
매 푸시마다 CI에서 이걸 돌릴 생각은 하지 마라. 그런 팀은 없다. 문제는 한 커밋당 4시간을 감당할 수 있느냐가 아니다. 테스트는 통과하는데 실제로는 아무것도 검증하지 않는 코드를 배포할 수 있느냐가 문제다.
100% 코드 커버리지는 허영 지표다
코드 커버리지는 테스트 중에 어떤 라인이 실행됐는지 측정할 뿐이다. 그 라인이 제대로 테스트됐는지는 측정하지 않는다.
테스트가 어떤 라인을 실행하고서도 의미 있는 assertion 없이 통과할 수 있다. 그런데도 커버리지에는 잡힌다. 뮤테이션 테스트는 이 문제를 해결한다. 코드를 조금씩 바꿔보고 테스트를 돌려서 실패하는지 확인한다. 의도적으로 코드를 망가뜨린 뒤에도 테스트가 통과한다면, 그 테스트는 쓸모없는 것이다.
문제는 규모다. 중간 규모의 JavaScript 프로젝트라도 10,000라인, 테스트 500개면 8,000개의 mutation이 생성될 수 있다. 각 mutation마다 전체 테스트 스위트를 돌리는 건 엄청난 연산이다. 평범한 CI 러너에서 그게 바로 4시간이 나오는 지점이다.
매 커밋마다 전체 스위트를 돌리는 건 애초에 불가능하다. 하지만 그래서 뮤테이션 테스트를 아예 걸너뛰는 건 아니다.
증분 뮤테이션 테스트가 유일한 실용적 접근법이다
현대의 뮤테이션 테스트 도구는 증분 분석을 지원한다. 전체 코드베이스를 mutate하는 대신, 현재 pull request에서 변경된 코드만 mutate한다.
200라인 정도 바뀐 평범한 PR이라면 40~80개의 mutation이 생성된다. 관련된 테스트 서브셋만 돌리면 몇 분이면 끝난다. 몇 시간이 아니라. 이렇게 해서 팀은 실제로 뮤테이션 테스트를 CI에서 쓴다.
JavaScript 뮤테이션 테스트 프레임워크 중 가장 널리 쓰이는 StrykerJS는 incremental 옵션을 통해 증분 모드를 지원한다. 뮤테이션 결과를 incremental.json 파일에 저장하고, 변경된 파일만 재분석한다.
증분 CI 실행을 위한 최소한의 stryker.conf.json 설정은 다음과 같다:
{
"packageManager": "npm",
"reporters": ["html", "clear-text", "json"],
"testRunner": "jest",
"coverageAnalysis": "perTest",
"incremental": true,
"incrementalFile": "reports/stryker-incremental.json",
"mutate": [
"src/**/*.js",
"!src/**/*.test.js",
"!src/**/__tests__/**"
],
"thresholds": {
"high": 80,
"low": 60,
"break": 50
}
}
coverageAnalysis: perTest 설정이 핵심이다. Stryker에게 각 mutated 파일을 커버하는 테스트만 실행하라고 지시한다. 전체 스위트가 아니라. 이 설정 하나만으로도 실행 시간이 한 자릿수 줄어든다.
thresholds 블록은 빌드가 실패하는 조건을 정의한다. 이 예시에서 mutation score가 50% 아래로 떨어지면 CI 파이프라인이 깨진다. 50~60%는 경고. 80% 이상이면 초록불이다.
실제로 통하는 세 가지 CI 패턴
뮤테이션 테스트를 성공적으로 쓰는 팀은 이걸 유닛 테스트처럼 돌리려 하지 않는다. 세 가지 패턴 중 하나를 쓴다.
메인 브랜치에서의 야간 전체 실행. 완전한 뮤테이션 스위트를 하루에 한 번, 보통 밤에 돌린다. 결과는 대시보드에 올리고 추이를 추적한다. 이렇게 하면 일상적인 개발을 막지 않으면서도 전반적인 테스트 품질 문제를 잡아낸다. 팀은 개별 스코어가 아니라 추이를 본다.
Pull request에서의 증분 실행. 변경된 파일만 mutate한다. CI 잡이 PR 파이프라인에 3~8분을 더한다. 변경된 코드의 mutation score가 threshold 아래로 떨어지면 PR은 블록된다. 뮤테이션 테스트가 가치를 발휘하는 지점이 바로 여기다. 새 코드가 코드베이스에 들어오는 순간.
주요 배포 전의 프리릴리즈 게이트. 일부 팀은 프로덕션 배포나 새 버전 릴리즈 전에 전체 뮤테이션 분석을 돌린다. 보안 감사나 성능 회귀 테스트와 유사한 품질 체크포인트로 취급한다. 모든 릴리즈가 아니라, 중요한 릴리즈에서만.
가장 많은 가치를 얻는 팀은 처음 두 패턴을 섞어 쓴다. 야간 실행은 전체 코드베이스의 건강도를 추적한다. PR 증분 실행은 새 코드의 품질을 강제한다.
뮤테이션 스코어는 목표가 아니다
뮤테이션 테스트가 정치적으로 위험해지는 지점이 여기다. 팀 전체의 mutation score를 공개하고 이를 성과 평가에 묶어버리면, 엔지니어들은 지표를 위한 최적화를 시작한다.
mutation을 죽이지만 실제 동작은 테스트하지 않는 테스트를 쓸 것이다. equivalent mutant — 원래 코드와 의미적으로 동일한 mutation — 를 스코어에서 제외해야 한다고 주장할 것이다. 유용한 테스트를 쓰는 대신 threshold를 만지작거리며 시간을 낭비할 것이다.
뮤테이션 테스트는 진단 도구지 리더보드가 아니다. 스코어는 조사할 신호일 뿐, 맞춰야 할 목표가 아니다.
훨씬 더 유용한 접근법은 뮤테이션 스코어의 추이를 추적하고, 새 코드에서 낮은 스코어를 대화의 출발점으로 삼는 것이다. “이 PR은 12개의 mutation을 도입하는데 죽은 게 4개뿐이야. 뭐가 빠졌는지 보자.” 이게 전체 저장소에서 73%를 보여주는 대시보드보다 훨씬 가치 있다.
실제 동작하는 GitHub Actions 워크플로우
아래는 pull request에서 증분 뮤테이션 테스트를 실행하고, 실행 간 증분 상태를 저장하는 프로덕션용 GitHub Actions 워크플로우다.
name: Mutation Testing
on:
pull_request:
branches: [main]
jobs:
stryker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Download previous incremental report
uses: actions/download-artifact@v4
with:
name: stryker-incremental
path: reports/
continue-on-error: true
- name: Run Stryker (incremental)
run: npx stryker run
- name: Upload incremental report for next run
uses: actions/upload-artifact@v4
with:
name: stryker-incremental
path: reports/stryker-incremental.json
if: always()
핵심 디테일은 fetch-depth: 0이다. Stryker는 PR 브랜치와 타겟 브랜치 사이에서 어떤 파일이 변경됐는지 알려면 전체 Git 히스토리가 필요하다. 없으면 증분 모드는 전체 실행으로 폴백된다.
워크플로우는 실행 전에 이전 stryker-incremental.json 아티팩트를 다운로드한다. 아티팩트가 없으면 첫 실행은 사실상 전체 분석이다. 이후 실행은 캐시된 결과를 재사용한다.
업로드 단계의 if: always()는 뮤테이션 테스트 잡이 threshold 위반으로 실패하더라도 증분 상태가 저장되도록 보장한다. 이게 없으면 다음 PR은 처음부터 다시 시작한다.
등가 뮤턴트는 여전히 문제다
어떤 뮤테이션 테스트 도구도 equivalent mutant를 확실히 감지할 수 없다. 이것들은 코드의 문법은 바꾸지만 의미는 바꾸지 않는 mutation이다. 고전적인 예시는 가환 연산에서 a = b + c를 a = c + b로 바꾸는 것이다. 기술적으로는 다른 코드지만 동작은 똑같다.
Equivalent mutant는 CI 시간을 낭비하고 엔지니어를 짜증 나게 한다. 현재 최선의 방법은 도구별 설정을 통한 수동 제외다. Stryker는 특정 mutator나 파일을 무시할 수 있다. Java용 PIT은 excludedMethods와 excludedClasses를 지원한다.
완벽한 해결책은 없다. 뮤테이션 테스트를 쓰는 팀은 기본적인 노이즈 수준을 받아들이고, 주기적으로 제외 목록을 검토한다.
우리 팀도 도입해야 할까?
뮤테이션 테스트는 공짜가 아니다. CI 연산, 도구 설정, threshold와 제외 목록의 지속적인 유지보수가 필요하다. 프로토타입이나 엔지니어 두 명짜리 프로젝트에는 과하다.
값어치가 있는 때는 코드베이스가 충분히 커서 관리 없이 테스트 품질이 떨어지기 시작하고, 팀이 충분히 커서 모든 PR을 꼼꼼히 리뷰하지 못할 때다. 프로덕션에서 테스트가 잡았어야 할 버그를 발견한 적이 있는데, 그 테스트는 존재하지만 실제로는 아무것도 assertion하지 않았다면, 뮤테이션 테스트가 그걸 잡아냈을 것이다.
가장 중요한 서비스부터 PR에서 증분 실행으로 시작하라. 한 달간 추이를 추적하라. 숫자가 유용한 이야기를 해준다면 확장하라. 그렇지 않다면, 잃은 건 몇 분의 CI 시간이지 4시간이 아니다.
처음 시작하는 팀을 위해, Stryker handbook에는 JavaScript, C#, Scala용 플랫폼별 가이드가 있다. JVM 프로젝트라면 PIT이 여전히 표준이다. 둘 다 증분 분석을 기본으로 지원한다.