
뮤테이션 테스트: 테스트의 품질을 테스트하다
코드 커버리지 100%인데 버그가 새나간다면? 테스트가 있다고 다 좋은 테스트가 아니다. 뮤테이션 테스트가 테스트 스위트의 진짜 품질을 드러내는 방법을 알아봤다.

코드 커버리지 100%인데 버그가 새나간다면? 테스트가 있다고 다 좋은 테스트가 아니다. 뮤테이션 테스트가 테스트 스위트의 진짜 품질을 드러내는 방법을 알아봤다.
REST는 왜 지금도 지배적인가, GraphQL은 어떤 문제를 해결하는가, gRPC는 언제 진짜 빛나는가. 세 프로토콜의 차이와 선택 기준을 실전 코드와 함께 정리했다.

Kubernetes는 처음엔 용어만 봐도 압도된다. Pod, ReplicaSet, Deployment, Service, Ingress가 각각 무엇이고 어떻게 연결되는지, ConfigMap과 Secret까지 실전 YAML과 함께 한 번에 정리한다.

단어와 문장을 숫자 벡터로 바꾸면 '의미'를 수학으로 계산할 수 있다. 코사인 유사도, ANN 알고리즘, OpenAI 임베딩 API까지 원리부터 실전까지 한번에 정리했다.

API는 한번 공개하면 마음대로 바꾸지 못한다. 클라이언트를 깨트리지 않으면서 API를 진화시키는 버저닝 전략 4가지를 비교하고, GitHub·Stripe·Twilio의 실제 선택을 분석한다.

커버리지 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)"이다.
뮤테이션 점수 = (살해된 뮤턴트 수 / 전체 뮤턴트 수) × 100
뮤테이션 점수가 높을수록 테스트가 실제 변화를 잘 감지한다는 의미다.
| 뮤테이션 점수 | 해석 |
|---|---|
| 90% 이상 | 우수한 테스트 스위트 |
| 70~90% | 양호, 개선 여지 있음 |
| 50~70% | 미흡, 주요 케이스 누락 가능 |
| 50% 미만 | 위험, 테스트 대폭 보완 필요 |
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분!)
실용적인 최적화 전략:
// stryker.config.mjs
const config = {
coverageAnalysis: 'perTest', // 각 테스트가 어떤 코드를 커버하는지 분석
// → 뮤턴트와 관련 없는 테스트는 실행 안 함 (속도 대폭 향상)
};
const config = {
mutate: [
'src/lib/pricing.ts', // 핵심 비즈니스 로직만
'src/lib/validation.ts',
// UI 컴포넌트, 설정 파일 등은 제외
],
};
// 증분 뮤테이션 테스트 (변경된 파일만)
const config = {
// stryker-incremental 플러그인 사용
incremental: true,
incrementalFile: '.stryker-tmp/incremental.json',
};
const config = {
concurrency: Math.max(1, os.cpus().length - 1), // CPU 1개 남기고 사용
};
CI에서는 매 커밋마다 전체 뮤테이션 테스트 돌리기보다, 주요 릴리즈 전이나 핵심 모듈 변경 시에만 돌리는 게 현실적이다.
# 가장 중요한 비즈니스 로직 파일 하나만 먼저
npx stryker run --mutate src/lib/pricing.ts
결과를 보고 팀과 공유. "이 케이스들이 테스트되지 않고 있었다"는 걸 보여주는 것만으로도 설득력 있다.
// stryker.config.mjs
const config = {
thresholds: {
high: 80, // 이 이상이면 초록
low: 60, // 이 미만이면 빨강
break: 50, // 이 미만이면 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/
두 지표의 차이를 명확히 이해하는 게 중요하다:
| 지표 | 측정 대상 | 한계 |
|---|---|---|
| 코드 커버리지 | 테스트 중 실행된 코드 비율 | 실행 여부만 봄, 검증 안 함 |
| 뮤테이션 점수 | 테스트가 탐지한 변형 비율 | 느리고 계산 비쌈 |
둘 다 필요하다. 커버리지는 빠르게 "테스트 안 된 영역"을 찾고, 뮤테이션은 깊게 "테스트가 진짜로 검증하는가"를 확인한다.
커버리지 낮음 + 뮤테이션 낮음 → 테스트가 없다
커버리지 높음 + 뮤테이션 낮음 → 테스트가 있지만 의미없다 (가장 위험)
커버리지 높음 + 뮤테이션 높음 → 진짜 좋은 테스트
뮤테이션 테스트는 느리고 계산 비용이 크다. 모든 프로젝트에 도입할 필요는 없다. 근데 핵심 비즈니스 로직 — 결제, 할인, 권한 등 — 은 이 수준의 검증이 필요하다.
처음엔 뮤테이션 점수가 낮게 나와서 충격받을 수 있다. 커버리지 100%인데 뮤테이션 점수 40%가 나오는 경우도 있다. 이게 충격적인 게 맞다. 근데 이걸 알기 전보다는 분명히 나은 상황이다.
코드의 면역 시스템을 강화하는 작업이다. 뮤턴트가 살아서 프로덕션에 나가지 않도록.