커버리지 100%인 프로젝트에서 버그가 프로덕션에 나간 경험이 있는가. 나는 있다. 처음엔 "테스트를 다 통과했는데 왜 버그가?" 싶었다. 알고 보니 테스트가 있긴 했는데, 그 테스트가 진짜로 뭔가를 검증하고 있지 않았던 거다.
// 커버리지는 100%지만 아무것도 테스트하지 않는 코드
it('할인 계산 함수가 실행된다', () => {
const result = calculateDiscount(100000);
expect(result).toBeDefined(); // 그냥 undefined 아닌지만 체크
});
result가 90000인지, 50000인지, 0인지 전혀 검증하지 않는다. 함수가 뭘 반환하든 테스트는 통과한다. 커버리지 도구는 "이 줄이 실행됐나"만 보기 때문에 100%를 찍는다.
이게 코드 커버리지의 근본적인 한계다. 그리고 이 문제를 해결하는 게 뮤테이션 테스트다.
뮤테이션 테스트란 무엇인가
뮤테이션 테스트는 SF 영화에서 돌연변이가 만들어지는 것처럼, 소스 코드에 의도적으로 작은 결함(뮤턴트)을 만든 뒤, 그 결함을 테스트가 감지해내는지 확인하는 방법이다.
유전학에서 빌려온 개념이다. 진짜 바이러스(뮤턴트)를 약하게 만든 뒤(변이) 주사해서 면역 시스템(테스트)이 반응하는지 본다. 반응하면 면역이 있는 것(테스트가 제대로 검증하고 있음), 반응 안 하면 취약한 것(테스트가 이 케이스를 못 잡음).
뮤턴트의 종류
뮤테이션 도구는 다양한 종류의 변이를 만든다:
// 원본 코드
function calculateDiscount(amount: number): number {
if (amount >= 100000) { // (1) 조건 변이 후보
return amount * 0.9; // (2) 산술 변이 후보
}
return amount;
}
조건 변이 (Conditional Mutation)
// 원본: amount >= 100000
// 뮤턴트 1: amount > 100000 (>= 를 > 로)
// 뮤턴트 2: amount <= 100000 (조건 반전)
// 뮤턴트 3: amount < 100000 (완전 반전)
// 뮤턴트 4: true (항상 참)
// 뮤턴트 5: false (항상 거짓)
산술 변이 (Arithmetic Mutation)
// 원본: amount * 0.9
// 뮤턴트 1: amount + 0.9
// 뮤턴트 2: amount - 0.9
// 뮤턴트 3: amount / 0.9
// 뮤턴트 4: amount * 0.1 (상수 변이)
반환값 변이 (Return Value Mutation)
// 원본: return amount;
// 뮤턴트 1: return 0;
// 뮤턴트 2: return -amount;
각 뮤턴트에 대해 테스트 스위트를 실행한다. 테스트가 실패하면 "살해됨(killed)", 통과하면 "생존(survived)"이다.
뮤테이션 점수 (Mutation Score)
뮤테이션 점수 = (살해된 뮤턴트 수 / 전체 뮤턴트 수) × 100
뮤테이션 점수가 높을수록 테스트가 실제 변화를 잘 감지한다는 의미다.
| 뮤테이션 점수 | 해석 |
|---|---|
| 90% 이상 | 우수한 테스트 스위트 |
| 70~90% | 양호, 개선 여지 있음 |
| 50~70% | 미흡, 주요 케이스 누락 가능 |
| 50% 미만 | 위험, 테스트 대폭 보완 필요 |
Stryker.js 설치와 사용
JavaScript/TypeScript에서 가장 인기 있는 뮤테이션 테스트 도구는 Stryker.js다.
설치
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
# Jest를 쓴다면:
# npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
설정 파일 생성
npx stryker init
또는 직접 stryker.config.mjs 작성:
// stryker.config.mjs
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
packageManager: 'npm',
reporters: ['html', 'clear-text', 'progress'],
testRunner: 'vitest',
coverageAnalysis: 'perTest',
// 뮤테이션 대상 파일
mutate: [
'src/**/*.ts',
'!src/**/*.test.ts',
'!src/**/*.spec.ts',
'!src/index.ts', // 엔트리 포인트는 제외
],
// 병렬 실행 설정 (CPU 코어 수에 따라 조정)
concurrency: 4,
// 타임아웃 설정 (느린 테스트의 경우 늘려야 할 수도)
timeoutMS: 60000,
};
export default config;
실행
npx stryker run
결과 읽기
Stryker 실행 후 reports/mutation/ 디렉토리에 HTML 리포트가 생성된다. 터미널 출력 예시:
Ran 312 tests in 12 seconds (30 survived out of 89 mutants)
----------|---------|----------|---------|---------|
File | % Score | # Killed | # Survived | # Timeout |
----------|---------|----------|---------|---------|
discount.ts | 66.3 | 59 | 30 | 0 |
cart.ts | 88.7 | 79 | 10 | 1 |
----------|---------|----------|---------|---------|
All files | 77.5 | 138 | 40 | 1 |
----------|---------|----------|---------|---------|
discount.ts의 뮤테이션 점수가 66.3%로 낮다. 30개 뮤턴트가 살아남았다는 건 테스트가 30가지 변형된 코드를 발견하지 못한다는 뜻이다.
실전: 생존한 뮤턴트 분석하기
HTML 리포트에서 어떤 뮤턴트가 살아남았는지 볼 수 있다. 예시:
// 원본 코드 (discount.ts)
export function calculateDiscount(amount: number): number {
if (amount < 0) {
throw new Error('Amount must be 0 or greater');
}
if (amount >= 100000) { // 뮤턴트: >= 를 > 로 변경했는데 테스트 통과함!
return amount * 0.9;
}
return amount;
}
살아남은 뮤턴트:
// 뮤턴트: amount >= 100000 → amount > 100000
// 이 뮤턴트가 살아남은 이유: amount === 100000인 케이스를 테스트 안 함
이걸 보고 경계값 테스트가 누락됐다는 걸 알게 된다:
// 기존 테스트 (경계값 누락)
describe('calculateDiscount', () => {
it('applies 10% discount for 150,000', () => {
expect(calculateDiscount(150000)).toBe(135000);
});
it('no discount for 50,000', () => {
expect(calculateDiscount(50000)).toBe(50000);
});
});
// 뮤턴트가 알려준 누락된 테스트
it('applies 10% discount at exactly the threshold (100,000)', () => {
expect(calculateDiscount(100000)).toBe(90000); // 경계값 테스트!
});
it('no discount for 99,999 (just below threshold)', () => {
expect(calculateDiscount(99999)).toBe(99999);
});
이런 식으로 생존한 뮤턴트는 "테스트하지 않은 케이스"를 정확히 가리켜 준다.
더 복잡한 예제: 이커머스 할인 정책
실제 비즈니스 로직을 가진 예제로 살펴보자:
// pricing.ts
interface DiscountRule {
minimumAmount: number;
discountRate: number;
maximumDiscount?: number;
}
const DISCOUNT_RULES: DiscountRule[] = [
{ minimumAmount: 300000, discountRate: 0.15, maximumDiscount: 50000 },
{ minimumAmount: 100000, discountRate: 0.10 },
{ minimumAmount: 50000, discountRate: 0.05 },
];
export function calculateFinalPrice(
amount: number,
couponCode?: string
): number {
if (amount <= 0) throw new Error('Invalid amount');
// 기본 할인 적용
const applicableRule = DISCOUNT_RULES.find(
rule => amount >= rule.minimumAmount
);
let discountAmount = 0;
if (applicableRule) {
discountAmount = amount * applicableRule.discountRate;
if (applicableRule.maximumDiscount) {
discountAmount = Math.min(discountAmount, applicableRule.maximumDiscount);
}
}
// 쿠폰 할인 적용 (추가 10%)
if (couponCode === 'EXTRA10') {
discountAmount += amount * 0.1;
}
return Math.max(0, amount - discountAmount);
}
뮤테이션 테스트 전 나쁜 테스트:
// 이런 테스트는 많은 뮤턴트를 살아남게 한다
describe('calculateFinalPrice', () => {
it('일반 케이스 동작', () => {
expect(calculateFinalPrice(200000)).toBeDefined();
});
it('쿠폰 적용', () => {
const withCoupon = calculateFinalPrice(100000, 'EXTRA10');
const withoutCoupon = calculateFinalPrice(100000);
expect(withCoupon).toBeLessThan(withoutCoupon);
});
});
뮤테이션 테스트 후 개선된 테스트:
describe('calculateFinalPrice', () => {
describe('기본 할인 규칙', () => {
it('30만원 이상: 15% 할인 (최대 5만원)', () => {
// 30만원 × 15% = 45,000원 할인
expect(calculateFinalPrice(300000)).toBe(255000);
});
it('30만원 이상 최대 할인 제한: 40만원은 5만원만 할인', () => {
// 40만원 × 15% = 60,000원 → 최대 5만원으로 제한
expect(calculateFinalPrice(400000)).toBe(350000);
});
it('10만원 이상 30만원 미만: 10% 할인', () => {
expect(calculateFinalPrice(200000)).toBe(180000);
});
it('5만원 이상 10만원 미만: 5% 할인', () => {
expect(calculateFinalPrice(80000)).toBe(76000);
});
it('5만원 미만: 할인 없음', () => {
expect(calculateFinalPrice(30000)).toBe(30000);
});
// 경계값 테스트 (뮤턴트가 알려준 누락 케이스)
it('정확히 30만원: 15% 할인 적용', () => {
expect(calculateFinalPrice(300000)).toBe(255000);
});
it('29,999원: 10% 할인만 적용', () => {
expect(calculateFinalPrice(299999)).toBe(269999.1);
});
});
describe('쿠폰 적용', () => {
it('EXTRA10 쿠폰: 기본 할인 후 추가 10% 할인', () => {
// 10만원: 기본 10% 할인 = 1만원, 쿠폰 10% = 1만원
// 총 할인 = 2만원, 최종 = 8만원
expect(calculateFinalPrice(100000, 'EXTRA10')).toBe(80000);
});
it('유효하지 않은 쿠폰: 기본 할인만 적용', () => {
expect(calculateFinalPrice(100000, 'INVALID')).toBe(90000);
});
});
describe('에러 케이스', () => {
it('0원 이하 금액은 에러', () => {
expect(() => calculateFinalPrice(0)).toThrow('Invalid amount');
expect(() => calculateFinalPrice(-1000)).toThrow('Invalid amount');
});
});
});
성능 고려사항
뮤테이션 테스트는 테스트 스위트를 수백~수천 번 실행한다. 느릴 수 있다.
뮤턴트 100개 × 테스트 실행 시간 30초 = 3000초 (50분!)
실용적인 최적화 전략:
1. coverageAnalysis 활용
// stryker.config.mjs
const config = {
coverageAnalysis: 'perTest', // 각 테스트가 어떤 코드를 커버하는지 분석
// → 뮤턴트와 관련 없는 테스트는 실행 안 함 (속도 대폭 향상)
};
2. 뮤테이션 대상 한정
const config = {
mutate: [
'src/lib/pricing.ts', // 핵심 비즈니스 로직만
'src/lib/validation.ts',
// UI 컴포넌트, 설정 파일 등은 제외
],
};
3. CI에서는 변경된 파일만
// 증분 뮤테이션 테스트 (변경된 파일만)
const config = {
// stryker-incremental 플러그인 사용
incremental: true,
incrementalFile: '.stryker-tmp/incremental.json',
};
4. 병렬 실행 조정
const config = {
concurrency: Math.max(1, os.cpus().length - 1), // CPU 1개 남기고 사용
};
CI에서는 매 커밋마다 전체 뮤테이션 테스트 돌리기보다, 주요 릴리즈 전이나 핵심 모듈 변경 시에만 돌리는 게 현실적이다.
뮤테이션 테스트 도입 전략
단계 1: 파일럿 실행
# 가장 중요한 비즈니스 로직 파일 하나만 먼저
npx stryker run --mutate src/lib/pricing.ts
결과를 보고 팀과 공유. "이 케이스들이 테스트되지 않고 있었다"는 걸 보여주는 것만으로도 설득력 있다.
단계 2: 임계값 설정
// stryker.config.mjs
const config = {
thresholds: {
high: 80, // 이 이상이면 초록
low: 60, // 이 미만이면 빨강
break: 50, // 이 미만이면 CI 실패
},
};
단계 3: CI 파이프라인 통합
# .github/workflows/mutation.yml
name: Mutation Tests
on:
# 매 PR마다 실행하면 너무 느릴 수 있음
# 핵심 모듈 변경 시에만
push:
branches: [main]
paths:
- 'src/lib/**'
jobs:
mutation-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx stryker run
- uses: actions/upload-artifact@v4
with:
name: stryker-report
path: reports/mutation/
코드 커버리지 vs 뮤테이션 점수
두 지표의 차이를 명확히 이해하는 게 중요하다:
| 지표 | 측정 대상 | 한계 |
|---|---|---|
| 코드 커버리지 | 테스트 중 실행된 코드 비율 | 실행 여부만 봄, 검증 안 함 |
| 뮤테이션 점수 | 테스트가 탐지한 변형 비율 | 느리고 계산 비쌈 |
둘 다 필요하다. 커버리지는 빠르게 "테스트 안 된 영역"을 찾고, 뮤테이션은 깊게 "테스트가 진짜로 검증하는가"를 확인한다.
커버리지 낮음 + 뮤테이션 낮음 → 테스트가 없다
커버리지 높음 + 뮤테이션 낮음 → 테스트가 있지만 의미없다 (가장 위험)
커버리지 높음 + 뮤테이션 높음 → 진짜 좋은 테스트
마무리: 면역 시스템을 강화하라
뮤테이션 테스트는 느리고 계산 비용이 크다. 모든 프로젝트에 도입할 필요는 없다. 근데 핵심 비즈니스 로직 — 결제, 할인, 권한 등 — 은 이 수준의 검증이 필요하다.
처음엔 뮤테이션 점수가 낮게 나와서 충격받을 수 있다. 커버리지 100%인데 뮤테이션 점수 40%가 나오는 경우도 있다. 이게 충격적인 게 맞다. 근데 이걸 알기 전보다는 분명히 나은 상황이다.
코드의 면역 시스템을 강화하는 작업이다. 뮤턴트가 살아서 프로덕션에 나가지 않도록.