정의
변이 테스트(mutation testing) 는 프로그램에 작은 결함을 하나씩 인위적으로 심은 변형본(mutant)을 만들고, 기존 테스트가 그 변형본을 실패로 잡아내는지 측정하는 기법이다. 측정 대상은 제품 코드가 아니라 테스트의 결함 검출력이다.
왜 필요한가
커버리지는 "테스트가 그 줄을 지나갔는가"만 센다. 지나갔지만 아무것도 확인하지 않은 줄도 100%로 계산된다.
it("주문을 생성한다", async () => {
await createOrder({ items });
// assertion 없음 — 커버리지는 올라가고 검증은 0
});
변이 테스트는 질문을 뒤집는다. "테스트가 코드를 실행했는가"가 아니라 "코드가 틀렸을 때 테스트가 빨개지는가" 를 묻는다. > 를 >= 로 바꾸고, && 를 || 로 바꾸고, 반환값을 null 로 바꾼 뒤에도 전부 초록이면, 그 테스트는 그 지점을 지키고 있지 않다는 직접적인 증거다.
특히 "일단 통과시키자"는 압박 속에서 assertion이 조금씩 느슨해진 테스트를 찾아내는 데 강하다. 커버리지처럼 숫자를 채우는 방식으로는 속일 수 없다.
동작 원리
- 원본 코드에 mutation operator를 적용해 결함 하나짜리 변형본을 대량 생성한다.
- 각 변형본에 대해 관련 테스트를 실행한다.
- 테스트가 실패하면 그 변형본은 killed, 전부 통과하면 survived로 기록한다.
mutation score = killed / (전체 − equivalent)를 계산한다.
대표적인 mutation operator는 다음과 같다.
| 연산자 | 변형 예시 | 잡히지 않으면 의심할 것 |
|---|---|---|
| 조건 경계 | a > b → a >= b | 경계값 테스트 부재 |
| 논리 연산자 | && → || | 복합 조건의 일부 분기 미검증 |
| 반환값 치환 | return x → return null | 반환값을 아무도 확인하지 않음 |
| 호출 제거 | logAudit() 삭제 | 부작용을 검증하지 않음 |
| 상수 변경 | 0 → 1 | 초기값·오프셋 검증 부재 |
equivalent mutant 는 코드를 바꿨지만 관찰 가능한 동작이 원본과 완전히 같아 원리상 죽일 수 없는 변형본이다. 자동 판별은 불가능하므로(정지 문제와 동치) 사람이 사유를 적어 제외해야 한다.
실무 적용
대부분의 도구는 변경분 한정 실행을 지원한다. 전체 실행은 매우 느리기 때문에 이 옵션이 사실상 필수다.
# JS/TS: Stryker
npx stryker run --incremental --mutate "src/domain/**/*.ts"
살아남은 변형본이 나오면 그 지점을 지키는 테스트를 추가한 뒤, 결함을 원래대로 되돌려 다시 초록이 되는지 확인한다.
// survived: `total > limit` → `total >= limit` 를 아무도 못 잡았다
it("한도와 정확히 같은 금액은 통과시킨다", () => {
expect(isOverLimit(1000, 1000)).toBe(false);
});
it("한도를 1원이라도 넘으면 막는다", () => {
expect(isOverLimit(1001, 1000)).toBe(true);
});
CI에는 변경 파일 대상으로만 붙이고, 전체 실행은 야간 작업으로 분리하는 구성이 무난하다.
트레이드오프
가장 큰 비용은 실행 시간이다. 변형본 수 × 테스트 시간이므로 단순 계산으로도 테스트 스위트의 수십~수백 배가 된다. 병렬화, 변경분 한정, 변형본이 지나가는 테스트만 고르는 최적화로 줄이지만 여전히 무겁다.
두 번째 비용은 equivalent mutant 판별에 드는 사람의 시간이다. 이것 때문에 점수는 결코 100%에 도달하지 않으며, 도달을 목표로 삼으면 팀이 무의미한 작업을 하게 된다.
얻는 것은 다른 지표가 주지 못하는 정보다. 커버리지는 "어디를 안 봤는가"를 알려 주지만, 변이 테스트는 "보긴 봤는데 못 잡는 곳이 어디인가" 를 정확한 줄 단위로 알려 준다.
사용하면 안 되는 경우
- 테스트가 아직 불안정한(flaky) 코드베이스. 실패가 결함 때문인지 흔들림 때문인지 구분되지 않아 결과 전체가 무의미해진다. 안정화가 먼저다.
- 판정 기준이 정리되지 않은 상태. 틀린 기대값을 기준으로 변형본을 죽이면 틀린 동작을 더 단단히 고정할 뿐이다. test-oracle이 선행 조건이다.
- UI 렌더링, 로그 포맷, 실험적 코드처럼 변경이 잦고 정확성 비용이 낮은 영역. 실행 비용 대비 얻는 게 적다.
- 점수를 KPI로 강제하려는 조직. 숫자를 채우려고 의미 없는 테스트를 늘리면 커버리지 때와 같은 왜곡이 반복된다.
흔한 실수
- 전체 코드베이스에 한 번에 돌린다. 결과가 나오는 데 몇 시간이 걸리고 아무도 보지 않게 된다. 위험도 높은 경계부터 좁게 시작한다.
- 변형본을 죽인 뒤 원복 확인을 건너뛴다. 정상 코드에서도 실패하는 테스트를 새로 만들어 넣는 사고가 여기서 나온다.
- 살아남은 변형본을 전부 equivalent로 치부한다. 근거를 적지 않으면 다음 사람이 같은 판단을 처음부터 반복한다.
- mutation score를 커버리지의 대체 지표로만 쓴다. 유용한 산출물은 점수가 아니라 살아남은 변형본의 목록이다. 그 목록이 테스트 공백의 좌표다.
관련 개념
- test-oracle — 무엇이 정답인지의 문제. 변이 테스트는 그 판정이 실제로 작동하는지 검증한다
- react-render-count-isolation-testing — assertion을 구체적으로 만들어 검출력을 올리는 다른 사례