AI가 작성한 코드를 어떻게 검증하는지에 대해 질문받았을 때, 얼마 전까지만 해도 저는 '테스트를 실행합니다'라고 대답했을 것 같습니다.
제 프로젝트에는 84개의 파일과 451개의 테스트가 있었습니다. 이들은 Claude Code가 작성하고 제가 리뷰하여 병합한 결과물입니다. 모든 테스트가 통과하면 안심했습니다.
어느 날, 친구에게 뮤테이션 테스트(Mutation Testing)에 대한 이야기를 들었습니다. 시험 삼아 이미 테스트가 존재하는 src/lib의 10개 파일을 대상으로 실행해 보기로 했습니다. 이 도구는 실제 코드에 550개의 작은 변경을 가하고, 테스트가 이를 감지하는지 확인합니다.
| 대상 | 변이체 수 | Killed | Survived | No coverage |
|---|
테스트가 있는 src/lib의 10개 파일 | 550 | 273 | 161 | 116 |
테스트가 감지한 비율은 49.6%였습니다. 161개는 테스트를 실행해도 통과했고, 116개는 해당 부분을 통과하는 테스트 자체가 없었습니다.
이것이 '451개의 테스트 중 절반이 무의미하다'는 의미는 아닙니다. 이번 대상을 통해 생성된 변이체의 약 절반을 감지하지 못했다는 결과입니다. 그럼에도 불구하고, 테스트가 무엇을 검증하고 있는지 재검토하기에는 충분한 결과를 얻었습니다.
제 프로젝트에는 다음과 같은 테스트들이 있었습니다. 설명만 한국어로 번역했습니다.
function source(relativePath: string): string {
return readFileSync(
fileURLToPath(new URL(`../${relativePath}`, import.meta.url)),
...
파일을 문자열로 읽어 특정 단어가 포함되어 있는지 확인하는 테스트입니다. 크레딧 계산을 실행하거나 그 결과를 검증하지는 않습니다.
이러한 테스트가 7개 파일에 62개 있었습니다. 규약(compliance) 체크로 유용할 때는 있습니다. 하지만 함수가 잘못된 값을 반환하더라도 이 테스트는 통과합니다. 동작이 올바른지 확인하는 테스트는 아닙니다.
그 외에도 확인하고 싶은 작성 방식들이 있었습니다.
- 요구사항을 확인하지 않고, 구현이 현재 반환하는 값을 그대로 기대값으로 설정하는 경우.
- 기대값을 계산하기 위해, 테스트 내에서도 실제와 동일한 알고리즘을 다시 작성하는 경우.
- mock을 호출했다는 사실만 확인하는 경우. 제 프로젝트에는
toHaveBeenCalled()가 139개 있었고, 각각이 무엇을 보장하고 있는지 재검토할 필요가 있었습니다.
- 테스트 파일 수나 커버리지(coverage)를 중요한 동작에 대한 단언(assertion)의 증거로 간주하는 경우.
이는 AI 고유의 문제라고 할 수는 없습니다. 다만, 같은 세션에서 동일한 에이전트에게 구현과 테스트를 맡기면, 양쪽 모두가 같은 오해를 물려받을 수 있습니다.
2026년 연구 All Smoke, No Alarm은 에이전트가 작성한 33,596건의 PR에 포함된 86,156개의 테스트 파일 패치를 조사했습니다. 구문 기반 분류로는 80.2%는 약한 검증 신호만 있거나 명시적인 검증 신호가 없는 것으로 나타났습니다.
이는 해당 데이터셋에서 관찰할 수 있는 단언 패턴에 대한 결과입니다. 'AI가 작성하는 테스트의 80.2%는 무의미하다'고까지는 말할 수 없습니다. 그럼에도 불구하고, 단순히 테스트 개수만 세는 것과 무엇을 검증하고 있는지 조사하는 것 사이의 차이는 전달됩니다. 논문
뮤테이션 테스트 도구는 실제 코드를 작게 변경합니다. <를 <=로 바꾸거나, +를 -로 바꾸거나, 조건을 false로 고정하거나, 반환값을 다른 값으로 바꿉니다.
변경한 코드 하나하나를 **변이체(mutant)**라고 부릅니다. 이 변이체에 대해 관련된 테스트를 실행합니다.
| 결과 | 의미 |
|---|
| Killed | 변이를 활성화했을 때, 적어도 하나의 테스트가 실패함 |
| ... | |
| 제가 주로 본 것은 이 세 가지였습니다. 실제로는 타임아웃이나 유효하지 않은 변이체 등 다른 상태도 있습니다. Stryker의 전체 점수는 유효한 변이체를 분모로 하며, 커버리지가 있는 코드만의 점수와는 분모가 다릅니다. Stryker의 상태 및 지표 | |
예를 들어, 다음 함수가 있습니다.
function isUserOldEnough(user: { age: number }) {
return user.age >= 18
}
>= 18을 > 18로 변경하는 경우입니다.
18세의 테스트만 있다면, 원래 구현과의 차이를 감지할 수 있습니다.
커버리지는 비교 처리가 실행되었음을 알려줍니다. 뮤테이션 테스트는 그 비교를 변경했을 때 어설션(assertion)이 차이를 발견하는지 확인하는 데 도움을 줍니다.
저는 Next.js 프로젝트에서 StrykerJS와 Vitest를 사용했습니다. 설정은 간단했습니다.
{
"testRunner": "vitest",
"plugins": ["@stryker-mutator/vitest-runner"],
...
}
Stryker와 Vitest runner를 설치하고 테스트 설정을 읽을 수 있는 상태에서 다음 명령어를 실행했습니다.
제 프로젝트의 경우, 첫 번째 실행은 1분 5초가 걸렸습니다.
disableTypeChecks: false는 Stryker가 일반적으로 삽입하는 @ts-nocheck를 넣지 않도록 하는 설정입니다. 이것만 지정한다고 해서 Vitest가 TypeScript의 타입 체크를 수행하는 것은 아닙니다. 타입 검증을 위해서는 별도의 체커(checker)나 명령어가 필요합니다. Stryker 설정
가장 인상 깊었던 부분은 kstRelativeTime이라는 날짜 표시 유틸리티였습니다. 이 함수는 "방금", "3시간 전", "어제"와 같은 문자열을 만듭니다. 11개의 테스트가 이 함수를 실행하고 있었는데, <를 <=로 바꾼 6개의 변이체(mutant)가 모두 살아남았습니다.
경계값 근처를 테스트하는 테스트는 있었지만, 경계값 자체를 테스트하고 있지는 않았습니다. 아무도 '정확히 48시간 전'일 때 어떤 표시가 올바른지 확인하지 않은 것입니다. 게다가 "몇 주 전"을 반환하는 분기문에는 커버리지 자체가 없었습니다.
그 외에도 눈에 띄는 결과들이 있었습니다.
- 동시 편집의 순서를 다루는 시퀀스 번호에서,
+ 1을 - 1로 바꿔도 통과한다. - 텍스트 병합 처리에서, if (local === remote)를 if (false)로 바꿔도 통과한다. - Markdown 이미지 파서에서는 109개의 변이체 중 단 6개만 감지할 수 있었다.
이들은 모두 이미 테스트가 존재하는 파일들이었습니다. CI(Continuous Integration)도 통과하고 있었습니다.
소스 문자열을 검사하는 테스트에서는 또 다른 문제가 있었습니다. 도구가 측정용 코드를 삽입하면 파일의 문자열 자체가 바뀝니다. 따라서 동작이 변경되어서 실패한 것이 아니라, 측정용 코드가 추가되었기 때문에 테스트가 실패할 수 있습니다. 이러한 테스트는 이번 뮤테이션 실행에서 제외했습니다.
테스트는 코딩 에이전트에게 결과를 반환하는 수단입니다. 어설션이 약하면 중요한 동작을 확인하지 않은 채 '완료'라는 결과만 돌려줄 수 있습니다.
뮤테이션 테스트가 있으면, "더 많은 테스트를 작성해 달라"고 요청하는 것보다 구체적인 자료를 제공할 수 있습니다. 살아남은 변경 사항을 보여주고, 원래의 동작과 변경 후의 동작을 구분할 수 있는 입력을 생각하게 할 수 있습니다. 그 위에서, 그 기대값이 정말로 요구사항에 부합하는지 제가 확인합니다.
Meta의 Automated Compliance Hardener도 규모는 다르지만 관련 방식을 사용하고 있습니다. LLM(대규모 언어 모델)으로 특정 우려 사항에 대응하는 변이체를 만들고, 기존 테스트가 놓치는 문제를 감지하는 테스트를 생성합니다. 보고된 실제 운영 사례에서는 9,095개의 변이체와 571개의 테스트를 생성했으며, Messenger와 WhatsApp의 test-a-thon에서는 엔지니어들이 테스트의 73%를 수용했습니다.
기존 테스트가 이미 통과하는 줄에서도 새로운 테스트가 다른 오류를 감지할 수 있다는 점. 이것이 커버리지만으로는 알 수 없는 부분이라고 생각합니다. Meta 논문
다만, 프로그램이 어떻게 동작해야 하는지는 제가 결정해야 합니다. 너무 에이전트에게 맡기게 되면, 같은 오해를 더 세밀하게 테스트로 작성하는 것에 그칠 수도 있습니다.
실행 시간이 신경 쓰입니다. 5분이 걸리는 테스트 스위트를 1,000개의 변이체에 대해 매번 모두 실행한다면 단순 계산으로 약 83시간이 소요됩니다.
현재 도구들은 이 반복을 줄여줍니다. Stryker는 테스트별 커버리지를 사용하여 실행 대상을 선택할 수 있고, incremental 모드에서는 적절한 조건에서 이전 결과를 재사용할 수 있습니다. 매번 리포지토리 전체를 대상으로 하는 것과 관련된 로직만 대상으로 하는 것은 부담이 크게 다릅니다. Stryker 설정, incremental 모드
Comwit와 그 신규 프로젝트 템플릿에는 다음 운영 방식을 적용했습니다.
- 대상은 먼저 로직에 한정합니다. 유틸리티, 서버 처리(server processing), API, state를 대상으로 했으며, 이 구성에서는 페이지(page), 라우팅(routing), UI 컴포넌트, DB 스키마는 제외했습니다.
- 평소에는 변경된 파일에 대해서만 실행합니다. 광범위한 범위의 실행은 가끔 기준을 확인하기 위해 사용합니다.
- 에이전트가 테스트를 변경하면, 변경된 부분에 대한 변이체 테스트(mutation test)를 실행하고 살아남은 변이체를 확인합니다.
- 생존한 이유를 나누어 처리합니다. 경계값이라면 그 값을 시험해보고, 검증되지 않은 분기라면 그 경로를 통과하는 입력을 추가합니다. 동작이 같거나 중요하지 않은 로그의 변경은 별도로 판단합니다.
제 리포지토리에는 pnpm test:mutation:changed라는 명령어를 준비했습니다. 이는 프로젝트 고유의 스크립트이며, Stryker에 기본으로 포함된 명령어는 아닙니다.
광범위한 범위에서도 한 번 실행해 보았습니다. 로직 902개 파일, 41,343개의 변이체가 대상이었고, 제 환경에서는 약 49분이 소요되었습니다.
| 대상 | 변이체 총수 | 커버리지 있음 | 생존한 변이체 (Survived) | 커버리지가 있는 변이체에 대한 점수 |
|---|
| 전체 대상 로직 | 41,343 | 10,230 | 3,799 | 62.9% |
src/server/repository | 12,516 | 5,121 | 2,021 | 60.5% |
src/server/domain-jobs | 2,490 | 1,084 | 459 | 57.7% |
여기서도 분모가 중요합니다. 62.9%는 생성된 41,343개 전체에 대한 수치가 아닙니다. 커버리지가 있는 변이체를 분모로 한 수치입니다.
생성된 변이체 중 커버리지가 있는 것은 약 4분의 1이었습니다. 게다가 그중에서도 3분의 1 이상이 살아남았습니다.
이 결과는 다음에 조사해야 할 장소를 알려주었습니다. 이 숫자를 실제 운영 환경에 몇 개의 버그가 있는지 추정하는 수치로는 사용하지 않습니다.
생존한 변이체가 반드시 요구사항의 검증 누락을 의미하는 것은 아닙니다. 코드를 변경해도 외부에서 관찰 가능한 동작이 바뀌지 않는 경우가 있습니다. 이러한 등가 변이체(equivalent mutants)는 동일하게 올바른 동작을 검증하는 테스트로는 감지할 수 없습니다.
변이체 테스트는 구현이 의도한 사양에 맞는 것임을 증명하지도 않습니다. 생성된 변경에 대해 테스트가 반응하는지를 조사합니다. 구현과 테스트가 같은 실수를 옳은 것으로 간주하고 있을 가능성은 남아 있습니다.
그래서 저는 변이체 점수(mutation score)를 팀의 KPI로 삼고 싶지는 않습니다. 숫자를 올리는 것이 목적이 되면, 실행한 이유를 잃어버릴 것 같습니다.
지금도 AI에게 테스트 작성을 맡기고 있습니다. 바꾼 것은 다음 질문입니다. '만약 이 코드가 조금 잘못되었다면, 어떤 테스트가 이를 감지할 수 있을까?'
첫 번째 실행에서는 약 1분 정도 걸린 변이체 테스트가 녹색 체크 표시만 보는 것보다 더 구체적인 리뷰 자료를 제공해 주었습니다.
저는 Comwit을 개발하고 있으며, 이 운영 방식을 신규 프로젝트 템플릿에 통합하고 있습니다. 본 기사는 2026년 10월 7일에 공개된 한국어 원고를 바탕으로 일본어로 정리되었습니다.
연락처: X @meursyphus