테스트는 통과한다. coverage 리포트는 87%라고 한다. 하지만 mutation score는 40%이고, 절반의 mutant가 여전히 살아있다.
그 40%는 코드가 망가졌다는 뜻이 아니다. 테스트가 망가졌다는 뜻이다. coverage는 테스트 실행 중 어떤 줄이 실행되었는지를 측정한다. mutation testing은 그 줄들이 잘못된 일을 하기 시작하면 테스트가 이를 알아챌 수 있는지를 측정한다. mutation score가 40%라는 것은, 코드에 도입될 수 있는 버그의 60%가 CI를 그냥 통과했을 것이라는 의미다.
surviving mutant는 실제로 무엇인가
surviving mutant는 테스트가 놓친 작은 인공 버그다.
mutation testing 도구는 소스 코드를 가져와서 미리 정의된 변환을 한 번에 하나씩 적용한다. >를 >=로 뒤집거나, +를 -로 바꾸거나, boolean 조건을 true로 교체할 수 있다. 각각의 변형된 코드 버전이 mutant다. 도구는 모든 mutant에 대해 테스트 스위트를 실행한다. 어떤 테스트라도 실패하면 mutant는 “killed”된다. 모든 테스트가 통과하면 mutant는 “survives”한다.
surviving mutant는 두 가지 중 하나를 의미한다. 테스트가 실제로 mutant가 깨뜨린 동작을 검증하지 않는 것이거나, mutant가 “equivalent”한 것이다(변환이 의미상 동일한 코드를 생성하는데, 이는 mutation testing에서 알려진 어려운 문제다).
대부분의 survivor는 equivalent하지 않다. 대부분은 걸어다니는 죽은 버그다.
구체적인 예시: 비밀번호 검증기
다음은 비밀번호가 정책 요구사항을 충족하는지 확인하는 함수다:
// password.js
function isValidPassword(password) {
if (password.length < 8) {
return false;
}
if (!/[A-Z]/.test(password)) {
return false;
}
if (!/[0-9]/.test(password)) {
return false;
}
return true;
}
module.exports = { isValidPassword };
그리고 100% line coverage를 주는 테스트 스위트가 있다:
// password.test.js
const { isValidPassword } = require('./password');
test('accepts a valid password', () => {
expect(isValidPassword('Hello1')).toBe(true);
});
test('rejects a short password', () => {
expect(isValidPassword('Hi1')).toBe(false);
});
test('rejects a password without uppercase', () => {
expect(isValidPassword('hello1')).toBe(false);
});
test('rejects a password without a digit', () => {
expect(isValidPassword('Hellooo')).toBe(false);
});
잠깐. isValidPassword('Hello1')은 true를 반환하지만, 'Hello1'은 여섯 글자에 불과하다. 첫 번째 검사에서 거부해야 한다. 테스트가 틀렸지만, 테스트 자체가 잘못된 동작을 단언하기 때문에 통과한다.
Stryker 같은 mutation testing 도구는 이를 잡아낼 것이다. 그 변형 중 하나는 길이 검사에서 <를 <=로 뒤집는 것이다. 그 mutant는 기존 테스트가 8글자 경계를 실제로 검증하지 않기 때문에 survive한다. 또 다른 변형은 첫 번째 if 블록 전체를 삭제하는 것이다. 이 mutant 역시 survive하는데, 테스트에 대문자나 숫자가 없는 8글자 비밀번호가 포함되어 있지 않기 때문이다. 길이의 상한은 다른 규칙과의 조합에서 전혀 테스트되지 않는다.
다음은 실제로 그 mutant들을 죽이는 테스트 스위트다:
// password.test.js
const { isValidPassword } = require('./password');
test('rejects password shorter than 8 chars', () => {
expect(isValidPassword('Hello1')).toBe(false);
});
test('accepts password exactly 8 chars with uppercase and digit', () => {
expect(isValidPassword('Hello1!@')).toBe(true);
});
test('rejects password without uppercase', () => {
expect(isValidPassword('hello1!@')).toBe(false);
});
test('rejects password without digit', () => {
expect(isValidPassword('Helloooo')).toBe(false);
});
test('rejects password missing both uppercase and digit', () => {
expect(isValidPassword('helloooo')).toBe(false);
});
이제 8글자 경계가 명시적으로 테스트된다. <= mutant는 'Hello1!@'(8글자)가 반드시 수락되어야 하기 때문에 실패한다. 삭제 mutant는 'helloooo'가 길이 검사 없이 통과할 수 있기 때문에 실패한다.
mutation testing의 실제 낮은 수준 작동 방식
mutation testing은 mutant마다 전체 테스트 스위트를 한 번씩 실행하기 때문에 계산 비용이 많이 든다.
코드베이스가 10,000줄이고 mutation 도구가 3,000개의 mutant를 생성한다면, 그것은 3,000번의 테스트 스위트 실행이다. 초기 학술 구현은 이 때문에 실제 코드베이스에서 거의 사용할 수 없었다. 현대의 도구들은 더 똑똑해졌다.
JavaScript와 TypeScript에서 가장 널리 사용되는 mutation testing 프레임워크인 Stryker는 여러 최적화를 사용한다:
-
Mutant scoping: Stryker는 초기 dry run의 coverage 데이터를 기반으로, 변형된 줄에 도달할 수 있는 테스트의 하위 집합만 실행한다.
-
Parallel execution: mutant는 worker 프로세스에서 평가된다.
-
Incremental mode: Stryker는 결과를 캐시하고, 마지막 실행 이후 변경된 코드의 mutant만 재평가한다.
-
Checkers: 컴파일 언어의 경우, Stryker는 전체 프로젝트를 재컴파일하지 않고도 AST 수준에서 mutant를 검증할 수 있다.
이러한 최적화에도 불구하고, 대형 코드베이스에서의 전체 mutation test 실행은 여전히 10-30분이 걸릴 수 있다. 이것이 대부분의 팀이 mutation testing을 PR이나 nightly build의 CI에서 실행하고, 저장할 때마다 실행하지 않는 이유다.
아무도 말하지 않는 트레이드오프
mutation testing은 공짜가 아니며, 항상 올바른 도구도 아니다.
equivalent mutant problem이 가장 큰 이론적 한계다. 어떤 변형은 관찰 가능한 동작을 변경하지 않는다. 다음을 생각해 보자:
const timeout = 1000 * 60;
이것을 1000 * 61로 바꾸는 변형은 의미상 다르다. 하지만 60 * 1000으로 바꾸는 변형은 equivalent하다. 값이 동일하기 때문에 어떤 테스트도 이를 죽일 수 없다. 일반적인 경우에서 equivalent mutant와 진짜 survivor를 구별하는 것은 결정 불가능하다. 현대 도구들은 명백한 경우를 건너뛰기 위해 휴리스틱을 사용하지만, 여전히 일부는 보게 될 것이다.
성능은 실제 문제다. 중간 규모의 TypeScript 프로젝트에서 Stryker는 2,000개의 mutant를 생성하고 이를 평가하는 데 15분이 걸릴 수 있다. PR에서 이를 활성화하면 매 실행마다 15분의 CI 시간이다. 팀은 일반적으로 임계값(예: mutation score가 60% 아래로 떨어지면 빌드 실패)부터 시작하고, 전체 분석은 nightly로 실행한다.
거짓된 자신감은 양날의 검이다. 100% mutation score가 버그가 없다는 뜻은 아니다. 도구의 mutation operator와 일치하는 버그가 통과하지 않았다는 뜻이다. mutation testing은 자신이 생성하는 방법을 모르는 버그는 발견할 수 없다. 요구사항의 논리적 오류, 시뮬레이션할 수 없는 race condition, 서비스 경계를 넘는 통합 실패는 잡아내지 못한다.
mutation testing을 실제로 시작하는 방법
JavaScript나 TypeScript를 작성하고 있다면, Stryker가 시작점이다.
설치:
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
stryker.config.mjs 생성:
// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
packageManager: 'npm',
reporters: ['html', 'clear-text', 'progress'],
testRunner: 'jest',
coverageAnalysis: 'perTest',
mutate: ['src/**/*.js'],
threshold: {
break: 60,
},
};
export default config;
실행:
npx stryker run
점수가 아닌 HTML 리포트부터 보자. 리포트는 각 surviving mutant를 소스 코드와 함께 인라인으로 보여준다. 처음 열 개의 survivor를 읽어보자. 각각에 대해 물어보자: 이 위치에서 실제 버그가 발생하면 프로덕션 문제가 될 것인가? 그렇다면 이를 잡을 테스트를 작성하라. 아니라면 코드가 과도하게 설계되었는지 고려해 보자.
100%를 쫓지 마라. 성숙한 코드베이스에서 70-80%는 강력한 점수다. 50% 미만이라면, 아마도 의미 있는 것을 단언하지 않고 코드를 실행하는 테스트가 있을 것이다. 90% 이상이라면, diminishing return과 증가하는 equivalent-mutant tax에 부딪히고 있을 가능성이 높다.
40%를 가지고 무엇을 해야 하는가
40% mutation score는 선물이다. 테스트가 어디에서 장식적인지 정확히 알려준다.
surviving mutant가 가장 많은 세 개의 파일을 고르자. 각 survivor를 읽고 어떤 단언이 빠졌는지 물어보자. 종종 수정은 간단하다: 테스트에서 함수를 호출했지만 반환값은 확인하지 않았다. 아니면 데이터를 파서에 통과시켰지만 파싱된 출력은 검증하지 않았다. 아니면 다른 입력으로 happy path를 세 번 테스트했지만 에러 분기는 테스트하지 않았다.
mutant는 노이즈가 아니다. 테스트되지 않은 버그가 숨어 있을 가능성이 가장 높은 곳의 순위 목록이다. 맨 위부터 시작하라.
FAQ
code coverage와 mutation testing의 차이점은 무엇인가? code coverage는 어떤 줄이 실행되었는지를 측정한다. mutation testing은 그 줄들에 버그가 있으면 테스트가 실패할지를 측정한다. coverage가 100%이고 mutation score가 40%라는 것은 모든 줄을 실행했지만, 테스트가 대부분의 줄이 틀렸음을 알아채지 못할 것이라는 의미다.
mutation testing이 기존 코드의 버그를 찾을 수 있는가? 아니오. mutation testing은 소스 코드가 아닌 테스트를 평가한다. 테스트가 어디에서 부족한지 알려준다. 코드가 올바른지는 말해주지 않으며, 특정 클래스의 오류를 테스트가 잡을 수 있는지만 알려준다.
어떤 언어에 좋은 mutation testing 도구가 있는가? JavaScript/TypeScript(Stryker), Java(PIT), C#(Stryker.NET), Python(mutmut), Rust(cargo-mutants)에 모두 성숙한 도구가 있다. 생태계는 성능과 지원되는 mutation operator에 따라 다르다.
mutation testing이 code coverage를 대체해야 하는가? 아니오. coverage는 저렴하고 빠르다. 개발 중 빠른 피드백을 위해 사용하라. mutation testing은 coverage가 볼 수 없는 사각지대를 찾는 주기적인 품질 게이트로 사용하라.