AI가 만든 테스트는 믿을 수 있을까? Mutation Testing으로 Coding Agent 테스트 품질 확인하기
Claude Code나 Codex 같은 Coding Agent에게 unit test를 작성하게 하는 일 자체는 이제 어렵지 않습니다. 제가 더 신경 쓰는 부분은 그 다음입니다. 테스트는 전부 green이고 Coverage도 높게 나왔는데, 이 테스트가 정말 잘못된 구현을 잡아낼 수 있는지는 별개의 문제입니다.
CI가 통과하면 이 차이는 쉽게 가려집니다. Agent가 몇 개의 case를 추가하고 line coverage와 branch coverage가 올라가면 작업이 끝난 것처럼 보입니다. 하지만 이런 지표는 주로 “코드가 실행되었다”는 사실을 보여 줍니다. 구현을 그럴듯하게 조금 잘못 바꿨을 때 이 테스트가 실제로 실패하는가까지 직접 답해 주지는 않습니다.
최근 공개된 mutagate는 mutation testing을 Coding Agent의 완료 흐름에 직접 연결합니다. 작성자의 Reddit 글에서 제시한 방식은 단순합니다. Agent가 테스트를 작성한 뒤 mutation testing을 실행하고, 기준을 넘지 못하면 바로 종료시키지 않고 surviving mutants를 다시 피드백으로 주어 테스트를 보강하게 합니다.
이 방식에서 제가 특히 흥미롭게 본 것은 피드백의 구체성입니다. Agent에게 “Coverage가 82%다”라고 알려 주는 것보다 “age >= 18을 age > 18로 바꿔도 테스트가 계속 통과한다”라고 알려 주는 편이 훨씬 유용합니다. 후자는 18이라는 경계값이 현재 테스트에 묶여 있지 않다는 사실을 바로 보여 줍니다.
그래서 이번에는 mutagate를 직접 설치하지 않았습니다. 대신 제 Content Lab에 작은 Node fixture를 직접 만들고 메커니즘만 따로 검증했습니다. 결과가 꽤 선명했습니다. baseline 테스트는 모두 통과했고 line / branch / function coverage가 전부 100%였지만, 6개의 controlled mutant 중 kill된 것은 3개뿐이었습니다.
테스트가 모두 통과해도 “잘못된 동작을 거부하는가”는 남는다
아주 평범한 함수를 하나 보겠습니다.
export function isAdult(age) {
return age >= 18;
}
테스트가 아래 두 개뿐이라고 해보겠습니다.
isAdult(20) === true
isAdult(10) === false
true와 false 두 경로를 모두 실행하므로 branch coverage는 100%가 될 수 있습니다.
하지만 구현을 다음처럼 바꿔도,
return age > 18;
두 테스트는 여전히 통과합니다.
Coverage가 쓸모없다는 뜻은 아닙니다. Coverage는 “어떤 코드 경로가 실행되었는가”를 알려 줍니다. assertion이 경계 의미까지 제대로 고정했는지는 다른 질문입니다.
Mutation testing은 반대 방향에서 확인합니다. 예를 들어 다음처럼 작고 명확한 잘못된 구현을 의도적으로 만듭니다.
>= -> >
> -> >=
조건 반전
branch 결과 교환
그리고 기존 테스트를 다시 실행합니다.
테스트가 실패하면 그 mutant는 killed입니다. 현재 suite가 그 동작 변화를 구별했다는 뜻입니다. 테스트가 계속 green이면 survived입니다. Coding Agent 입장에서 survivor가 특히 유용한 이유는 “테스트를 더 잘 써라” 같은 모호한 지시가 아니라, 현재 테스트가 구분하지 못한 구체적인 동작을 알려 주기 때문입니다.
이 아이디어 자체가 mutagate에서 처음 나온 것은 아닙니다. 이전의 MuTAP 연구도 surviving mutants를 LLM 기반 test generation의 피드백으로 다시 넣어 테스트를 강화했습니다. 지금은 이 feedback loop를 Coding Agent의 lifecycle에 자연스럽게 붙일 수 있는 도구가 늘어난 셈입니다.
Claude Code의 현재 Hooks reference는 PostToolUse, SubagentStop, TaskCompleted, Stop 같은 lifecycle event를 제공하고 async hook도 지원합니다. 그래서 “Agent가 테스트 작성 -> deterministic check 실행 -> 완료 허용 여부 결정” 같은 흐름은 prompt에 매번 자기 검증을 기억하라고 적는 것보다 실행 가능한 guardrail에 가깝게 만들 수 있습니다.
이건 제가 전에 쓴 CLAUDE.md가 너무 길어졌을 때 AGENTS.md, Skills, CI의 경계를 다시 나눈 글과 같은 방향입니다. 매번 반드시 실행되어야 하는 규칙은 결국 “모델이 기억해 주길 바라는 것”에서 executable check로 옮기는 편이 더 안정적입니다.
직접 A/B를 돌려 보니 Coverage는 100% 그대로인데 Mutation Score는 50%에서 100%로 바뀌었다
실험 범위를 작게 유지하려고 third-party repository 코드는 전혀 실행하지 않았고 mutation framework도 설치하지 않았습니다. 직접 만든 Node fixture에 경계가 있는 세 개의 작은 함수만 두었습니다.
isAdult(age) // age >= 18
discount(total) // total > 100
shipping(total) // total >= 50 ? 0 : 5
그리고 해석하기 쉬운 controlled mutant 6개를 직접 정의했습니다. 명확한 조건 반전도 넣었고, Coverage만 보면 놓치기 쉬운 경계 변화도 넣었습니다.
Baseline 테스트는 6개입니다. 각 branch의 양쪽을 모두 실행하지만 정확한 경계값은 의도적으로 테스트하지 않았습니다.
GitHub Actions 결과는 다음과 같았습니다.
| 지표 | Baseline |
|---|---|
| 일반 테스트 | PASS |
| Line coverage | 100% |
| Branch coverage | 100% |
| Function coverage | 100% |
| Mutants killed | 3 / 6 |
| Mutants survived | 3 / 6 |
| Mutation score | 50% |
survived한 세 mutant는 모두 경계 변화였습니다.
age >= 18 -> age > 18
total > 100 -> total >= 100
total >= 50 -> total > 50
저에게는 “50%”라는 숫자보다 이 세 줄이 훨씬 유용했습니다. 다음에 어떤 assertion을 추가해야 할지가 바로 보였기 때문입니다.
추가한 테스트는 세 개뿐입니다.
isAdult(18) === true
discount(100) === false
shipping(50) === 0
production code는 바꾸지 않았고 mutation matrix도 그대로 유지했습니다. 다시 실행하니 6/6 mutant가 모두 killed가 되었고 score는 50%에서 100%로 올라갔습니다.
여기서 중요한 것은 “100%”라는 숫자가 아닙니다. 그 숫자에 집착하면 또 다른 지표 경쟁이 됩니다. 실제로 유용했던 것은 이 loop였습니다.
Agent가 테스트 작성
-> 일반 테스트 PASS
-> controlled fault 주입
-> survivors 수집
-> survivors를 구체적인 피드백으로 변환
-> Agent가 boundary / negative assertion 추가
-> 다시 검증
Coverage는 두 번 모두 100%였고 새 정보를 주지 못했습니다. 반면 survivor list는 빠진 세 경계값을 정확히 보여 줬습니다.
이 결과를 보고 나니 mutation testing을 “품질 점수 하나 더 추가하기”보다 Coding Agent를 위한 fault feedback generator로 보는 편이 더 자연스럽다고 느꼈습니다.
80% 기준보다 survivor를 Agent에게 돌려주는 쪽이 더 중요하다
mutagate의 현재 default는 이 설계를 이해하기 좋은 예입니다. 제가 확인한 pinned version에서는 mode가 gate, mutation threshold가 0.8, maxRounds가 2입니다. 기준보다 낮으면 완료를 막고 Agent에게 테스트를 다시 보강할 기회를 줍니다. mutation infrastructure 자체가 실패하면 fail-open 쪽으로 처리해 깨진 gate가 Agent를 영구적으로 가두지 않도록 합니다.
이 방향은 이해되지만 저는 “80%”를 모든 repository의 표준으로 복사하지는 않을 겁니다.
mutation score 역시 proxy이기 때문입니다.
2026년의 LLM-generated test replication study는 꽤 신중한 결론을 냅니다. Coverage와 mutation score가 real-bug detection을 얼마나 잘 대변하는지는 평가 상황에 따라 달라집니다. 원본 코드가 올바르다고 가정하고 여러 test generation 방식을 비교할 때는 유용한 신호가 될 수 있지만, code under test 자체에 이미 대상 bug가 존재할 수 있는 환경에서는 해석이 단순하지 않습니다.
또 다른 2026년 Python class-level test generation 연구도 structural coverage는 쉽게 높은 수준에 도달해 구분력이 떨어질 수 있다고 봅니다. 실무적으로는 executability를 먼저 gate로 삼고 Coverage, mutation testing, structural quality를 함께 보는 편이 더 낫습니다.
Coding Agent workflow에 mutation testing을 넣는다면 일반 테스트는 기본 실행 가능성과 regression을 담당하게 합니다. mutation은 “현재 assertion으로 구별하지 못하는 잘못된 동작”을 찾습니다. integration, E2E, property, security, domain test는 mutation testing이 대체하지 못하는 층을 계속 담당합니다.
threshold도 tool default보다 repository 자체 baseline에서 정하는 편이 낫습니다. legacy code가 많으면 equivalent mutant나 unreachable code가 많을 수 있고 test suite가 매우 느릴 수도 있습니다. 반대로 작은 신규 library는 높은 mutation score를 쉽게 만들 수 있습니다. 더 중요한 것은 중요한 path에서 survivor가 반복되는지, 그리고 그 survivor가 의미 있는 새 assertion으로 이어지는지입니다.
그래서 Agent에게 줄 피드백이라면 score보다 survivor가 더 실용적입니다. 숫자는 “기준에 못 미쳤다” 정도만 말합니다. 구체적인 mutant는 “이 동작은 현재 테스트가 제약하지 못한다”고 알려 줍니다.
이 gate는 공짜가 아니다
Mutation testing은 코드를 여러 번 바꾸고 테스트를 반복 실행해야 하므로 비용이 있습니다.
이번 GitHub Actions 실험에서 일반 테스트와 mutation loop를 각각 5번 실행했습니다. 아주 작은 fixture인데도 중앙값은 다음과 같았습니다.
| 실행 | 중앙값 |
|---|---|
| 일반 테스트 | 98.331 ms |
| Mutation loop | 711.798 ms |
| 추가 시간 | +613.467 ms |
| 비율 | 7.24× |
이 7.24×를 “mutation testing은 보통 7배 느리다”라고 일반화하면 안 됩니다. 이 숫자는 작은 synthetic Node fixture와 해당 GitHub Actions runner에서 나온 결과일 뿐입니다. 다만 여러 mutant마다 테스트를 다시 실행하면 추가 비용이 분명히 생긴다는 점은 확인할 수 있습니다.
실제 repository에서는 처음부터 every Agent stop에 full mutation을 걸지 않을 겁니다. Agent가 방금 수정한 module이나 test로 target을 좁혀 빠른 gate를 두고, full mutation suite는 CI, scheduled run, 또는 위험도가 높은 변경에만 실행하는 편이 현실적입니다.
equivalent mutants와 unreachable mutants도 mutation score를 왜곡할 수 있습니다. 전자는 코드가 바뀌어도 의미가 같고, 후자는 실행 자체가 불가능합니다. 이런 mutant가 survived했다고 해서 테스트가 약하다고 단정할 수 없습니다. tool이 이를 잘 처리하지 못하면 Agent가 실제로 kill할 수 없는 숫자를 쫓게 됩니다.
infrastructure error도 테스트 품질과 분리해야 합니다. mutation runner crash, dependency 누락, timeout은 “테스트가 나쁘다”는 증거가 아닙니다. mutagate의 fail-open 선택도 이런 상황에서 개발 loop 전체를 잠가 버리는 문제를 피하려는 방향으로 볼 수 있습니다.
security boundary 역시 중요합니다. mutagate의 security documentation에는 hook이 실행하는 build / test가 사용자 권한으로 실행되며 Agent sandbox 안에 자동으로 갇히는 것은 아니라고 적혀 있습니다. 새 third-party hook을 장기 개발 환경에 넣을 때는 아이디어가 좋아 보인다는 이유만으로 바로 설치하기보다 코드를 읽고 version을 고정하고 어떤 command를 실행하는지 먼저 확인하고 싶습니다.
저는 Mutation Testing을 “완료 증거” 계층에 두고 싶다
지금은 Coding Agent의 테스트 작업을 두 가지로 나눠 봅니다.
첫 번째는 테스트 생성입니다. 현재 모델은 happy path, mock, 흔한 boundary case를 꽤 빠르게 작성합니다.
두 번째는 그 test suite를 믿을 만한지 증명하는 일입니다. 이 부분까지 모델의 self-assessment에 전부 맡길 필요는 없습니다.
green test는 첫 번째 gate가 될 수 있습니다. Coverage는 구조적인 blind spot을 보여 줄 수 있습니다. mutation survivors는 assertion이 그럴듯한 잘못된 동작을 실제로 구별하는지 확인합니다. 중요한 업무 path라면 integration, E2E, property, domain test도 계속 필요합니다.
이렇게 보면 Mutation Testing의 위치가 명확해집니다. 새로운 진리 점수도 아니고 Coverage의 대체재도 아닙니다. “테스트 작성을 끝냈다”는 Agent의 선언에 조금 더 속이기 어려운 evidence를 붙이는 계층입니다.
mutagate는 아직 매우 새로운 project라서 이번 작은 실험만으로 production standard라고 부를 수는 없습니다. 하지만 workflow의 핵심 아이디어에는 동의합니다. Agent가 끝내기 전에 테스트가 통과하는지만 보지 말고, 구현을 일부러 조금 틀리게 만든 뒤 테스트가 실제로 반대하는지 확인해 보는 것.
사람이 쓴 테스트에도 원래 가치가 있는 질문입니다. Coding Agent가 점점 더 많은 테스트를 자동 생성하게 될수록 이 확인은 오히려 더 중요해질 것 같습니다.