Mutation testing 리포트는 survivor로 가득 차 있고, 그중 최소 하나는 도저히 이해할 수 없는 상태다.

도구는 47번째 줄의 >>=로 뒤집었거나, 전체 조걸문 블록을 true로 바꿨거나, 테스트 대상인지도 몰랐던 문자열 리터럴을 mutate했다고 말한다. diff를 세 번이나 읽어봤다. 그래도 mutant가 어떤 동작을 깨뜨렸는지, 어떤 테스트가 잡아낼 수 있는지 이해하지 못한다. 그래서 건너뛴다. Mutant는 살아남는다. 점수는 계속 낮다.

이것이 mutation testing 도입이 멈춰 서는 가장 흔한 이유다. 런타임 때문이 아니다. Equivalent mutant 때문도 아니다. 엔지니어가 survivor를 응시하며 누락된 테스트와 매핑하지 못하고, mutation testing이 그저 소음이라고 결심하는 순간 때문이다.

그렇지 않다. 다른 출발점이 필요할 뿐이다.

문제: Mutation에서 출발하지 말고 Code에서 출발하라

대부분의 개발자는 surviving mutant를 거꾸로 접근한다. Mutation diff를 읽고, 어떤 synthetic bug가 주입되었는지 이해하려 한 뒤, 그 특정 버그를 잡아낼 테스트를 떠올리려 한다.

명백한 경우에는 통한다. 미묘한 경우에는 실패한다.

Mutation은 세 단계 깊은 helper function 안에 있을 수도 있다. 존재하는지도 몰랐던 side effect에 영향을 줄 수도 있다. Generated code나 framework callback 안에 있을 수도 있다. Diff는 무엇이 바뀌었는지 보여주지만, 기존 테스트가 신경 쓰지 않았는지는 보여주지 않는다. Mutation을 해독하는 것부터 시작한다면, synthetic code에 대한 reverse engineering을 하는 셈이다. 이는 경험이 많은 엔지니어에게도 어렵다.

더 나은 접근법은 mutation을 완전히 무시하고, survivor를 synthetic bug에 대한 신호가 아니라 code에 대한 신호로 다루는 것이다.

Surviving Mutant는 테스트가 검증하지 않는 한 줄일 뿐이다

모든 surviving mutant는 테스트 중 실행되었지만, 그 output이나 side effect가 단 한 번도 assert되지 않은 코드의 한 줄을 가리킨다.

Mutation은 무엇이든 될 수 있다. 그것이 살아남았다는 사실은 한 가지를 의미한다: 그 줄이 잘못된 결과를 냈을 때에도 테스트는 통과할 것이다. 이를 고치기 위해 specific mutation을 이해할 필요는 없다. 그 줄이 무엇을 해야 하는지 이해하고, 그것이 실제로 했는지 확인하는 테스트를 작성해야 한다.

이렇게 관점을 바꾸면 문제는 synthetic diff를 reverse-engineering하는 것에서 일반적인 test design으로 바뀐다.

방법: Mutation에서 앞으로가 아니라 코드 줄에서 거꾸로 작업하라

Diff가 아무리 혼란스러워 보여도, 어떤 surviving mutant에든 통하는 4단계 프로세스를 소개한다.

Step 1: Mutation이 닿은 정확한 줄을 찾는다

Mutation testing 도구의 HTML 리포트는 source code와 함께 mutated line을 인라인으로 보여준다. 해당 파일을 열고 diff가 아닌 원본 줄을 찾는다.

예를 들어 Stryker가 이 함수에서 survivor를 리포트했다고 하자:

// pricing.js
function calculateDiscount(price, customer) {
  if (customer.loyaltyYears > 5) {
    return price * 0.85;
  }
  if (customer.isStudent) {
    return price * 0.90;
  }
  return price;
}

module.exports = { calculateDiscount };

Mutation은 첫 번째 조걸문의 >>=로 바꿨다. 이 디테일이 혼란스러울 수 있다. 지금은 잊어버리자. 해당 줄은 if (customer.loyaltyYears > 5)이다.

Step 2: 이 줄이 무엇을 강제하려는지 묻는다

Mutation에 대해 생각하지 말라. Business rule을 생각하라.

이 줄은 고객이 5년 이상 충성 고객인지 확인해야 한다. 참이면 15% 할인을 받는다. 경계값이 중요하다. 정확히 5년 된 고객은 이 할인을 받지 말아야 한다. 6년 된 고객은 받아야 한다.

이제 기존 테스트를 보자:

// pricing.test.js
const { calculateDiscount } = require('./pricing');

test('returns full price for new customers', () => {
  expect(calculateDiscount(100, { loyaltyYears: 0 })).toBe(100);
});

test('gives loyalty discount to long-term customers', () => {
  expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});

test('gives student discount to students', () => {
  expect(calculateDiscount(100, { isStudent: true })).toBe(90);
});

테스트는 첫 번째 if 문의 두 branch를 모두 커버한다. 하지만 경계값은 테스트하지 않는다. loyaltyYears: 5는 한 번도 나오지 않는다. 그래서 >= mutant가 살아남은 것이다. 도구는 당신이 몰랐던 틈을 찾아낸 것이다.

Step 3: 이 줄이 틀렸을 때 실패할 테스트를 작성한다

이 specific mutation을 죽이는 테스트를 작성할 필요는 없다. Business rule이 위반되었을 때 실패할 테스트를 작성해야 한다.

// pricing.test.js
test('does not give loyalty discount at exactly 5 years', () => {
  expect(calculateDiscount(100, { loyaltyYears: 5 })).toBe(100);
});

test('gives loyalty discount at 6 years', () => {
  expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});

이제 경계값이 명시적이다. 누군가 >>=로 바꾸면, 첫 번째 테스트는 실패한다. 정확히 5년 된 고객이 잘못된 할인을 받게 되기 때문이다. Mutant는 죽는다. Synthetic diff에서 >=가 무엇을 의미하는지 이해할 필요가 전혀 없었다.

Step 4: Mutation test를 다시 실행하고 확인한다

Mutation 도구를 이 파일만 대상으로 실행하거나, 여유가 있다면 전체 suite을 실행하라. Survivor는 사라져 있어야 한다. 사라지지 않았다면, 테스트가 실제로 생각한 줄을 실행하고 있지 않은 것이다. Coverage data를 확인하라.

코드 줄 자체가 혼란스러울 때

때로 mutated line은 library wrapper, framework hook, 또는 직접 작성하지 않은 generated code 안에 있다. 이런 경우 survivor는 다른 것을 알려준다: codebase에는 충분히 이해하여 테스트할 수 있는 사람이 없는 코드가 있다.

이것은 mutation testing의 문제가 아니다. Mutation testing이 드러낸 code quality 문제다.

선택지는 mutation testing이 없었을 때와 같다: 테스트 가능한 surface를 가질 때까지 리팩토링하거나, 이 코드가 테스트되지 않는다는 것을 받아들이고 그렇게 표시하라. 일부 도구는 특정 줄이나 파일을 무시할 수 있게 해준다. 그 권한은 아껴 써라. 무시한 mutant 하나하나가 출시될 수 있는 버그다.

어려운 경우: Side Effect를 바꾸는 Mutation

경계값 확인은 쉽다. Side effect는 더 어렵다.

이 함수를 보자:

// logger.js
function logError(error, context) {
  const timestamp = new Date().toISOString();
  console.error(`[${timestamp}] ${context}: ${error.message}`);
  metrics.increment('error.count');
}

module.exports = { logError };

Mutation testing 도구는 전체 console.error 호출을 아무것도 없는 것으로 바꾸거나, string template을 빈 문자열로 바꿀 수 있다. 테스트가 log output을 검증하지 않으면 이런 mutant가 살아남는다.

대부분의 팀은 logging을 테스트하지 않는다. 보통 괜찮다. 하지만 log가 alerting system에 소비되거나, metrics.increment가 on-call을 호출하는 dashboard를 구동한다면 이런 테스트를 건너뛰는 것은 위험하다.

접근법은 같다. Mutation을 연구하지 마라. 이 줄이 어떤 동작을 해야 하는지 물어라. 답이 “timestamp가 있는 structured log entry”라면, log output을 assert하는 테스트를 작성하라:

// logger.test.js
const { logError } = require('./logger');

test('logs error with timestamp and context', () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  logError(new Error('db timeout'), 'payment-service');
  expect(spy).toHaveBeenCalledWith(
    expect.stringMatching(/\d{4}-\d{2}-\d{2}T.*payment-service.*db timeout/)
  );
  spy.mockRestore();
});

console.error 호출을 삭제하는 mutant는 이제 spy가 호출을 감지하지 못하기 때문에 실패한다. String template을 망가뜨리는 mutant는 regex가 매치되지 않기 때문에 실패한다. 어떤 mutation도 이해할 필요가 없었다.

이 접근법이 Mutation을 연구하는 것보다 확장성이 좋은 이유

가능한 mutation은 무한하다. Code가 가져야 할 동작의 양은 유한하다.

Specific mutation을 죽이는 테스트를 작성하려 한다면, synthetic bug와 두더지 잡기를 하는 셈이다. Code의 실제 동작을 검증하는 테스트를 작성한다면, mutation은 부수효과로 죽는다. 두 번째 접근법은 지속 가능하다. 첫 번째는 아니다.

이것이 mutation tool에 너무 밀접하게 결합된 테스트를 피하는 방법이기도 하다. 47번째 줄에 >가 사용된다고 assert하는 테스트는 brittle하다. 5년 된 고객이 정가를 지불한다고 assert하는 테스트는 올바르다.

한계: Equivalent Mutant는 여전히 존재한다

이 방법은 equivalent mutant에는 도움이 되지 않는다. Equivalent mutant는 누락된 테스트를 나타내지 않기 때문이다. 동일한 동작을 만드는 transformation을 나타낸다.

Commutative operation에서 mutation이 a + bb + a로 바꾼다면, 어떤 테스트도 이를 죽일 수 없다. Assert할 누락된 동작이 없다. 이것은 false positive이며, 모든 mutation testing 도구에 존재한다. 이를 인식하고, 무시하고, 넘어가는 법을 배워라. 2%의 equivalent-mutant noise floor 때문에 나머지 98%도 noise라고 믿게 하지 마라.

가장 나쁜 세 파일부터 시작하라

Mutation score가 낮고 수십 개의 survivor가 있다면, 전부 이해하려 하지 마라. Survivor가 가장 많은 세 파일을 고른다. 각 파일에서 가장 의심스러운 세 줄을 고른다. 각각에 이 방법을 적용하라.

한 시간 안에 codebase를 더 올바르게 만드는 아홉 개의 테스트를 작성할 것이다. Mutation testing을 다시 실행하라. 점수가 뛸 것이다. 더 중요한 것은, 이전보다 자신의 코드를 더 잘 이해하게 될 것이다.

Mutant는 당신이 그것을 이해하길 바라는 것이 아니다. 당신이 자신의 코드를 이해하길 바라는 것이다.


FAQ

테스트를 작성하려면 mutation operator를 이해해야 하나요?

아니오. Mutation operator는 주의를 산만하게 할 뿐이다. 원본 줄이 무엇을 해야 하는지에 집중하라. 그 동작에 대한 테스트를 작성하라. Mutant는 부수효과로 죽을 것이다.

Mutated line이 직접 테스트할 수 없는 private function 안에 있다면?

그것은 design signal이다. 테스트할 가치가 있는 동작을 가진 function이라면 테스트 가능해야 한다. 테스트를 위해 expose하거나, 이를 호출하는 public API를 통해 테스트하라. Public API 테스트가 그 동작에 도달할 수 없다면, 그 동작은 dead code일 수 있다.

모든 surviving mutant를 죽여야 하나요?

아니오. 일부 mutant는 logging, metrics, 또는 다른 observability code에 닿는데, 테스트 비용이 가치를 초과한다. Codebase에 맞는 threshold를 설정하고, 에너지를 business logic의 mutant에 집중하라.

테스트가 mutant를 죽이지만 여전히 뭔가 잘못된 느낌이 든다면?

그 느낌을 믿어라. 우연히 mutant를 죽이지만 business rule을 명확히 assert하지 않는 테스트는 technical debt다. Test-language가 아닌 domain language로 기대 동작을 표현하도록 다시 작성하라.