요즘 AI에게 기능을 시키면 테스트까지 같이 써 줍니다. 그리고 거의 항상 이렇게 끝납니다. "테스트 전부 통과했습니다."
저도 처음엔 그 말을 믿었습니다. 그러다 습관 하나를 들였습니다. 소스 코드에 버그를 일부러 심고, 테스트가 빨개지는지 보는 것. 빨개지면 그 테스트는 뭔가를 지키고 있는 거고, 초록으로 남으면 아무것도 안 지키고 있는 겁니다.
한 달 동안 이걸 해 보니, 초록불 테스트가 버그를 놓친 이야기만 블로그 초안으로 열여섯 편이 쌓였습니다. 그중 대표적인 걸 골라 정리했습니다.
먼저 결론부터 봅시다.
초록불은 테스트가 본 것만 증명합니다. 안 본 곳에 심은 버그는 그대로 통과합니다.
테스트가 놓치는 자리는 대개 넷입니다. 일부만 본 경우, "없다"를 확인하는 경우, 내 컴퓨터가 정답을 내는 경우, 데이터가 우연히 맞는 경우.
그리고 제일 중요한 것. 테스트를 쓴 쪽이 버그까지 심으면, 버그도 같은 사각지대에 심깁니다. 버그는 다른 눈이 심어야 합니다.
거창한 도구가 필요 없습니다. 소스에서 한 줄을 바꾸고, 테스트를 돌리고, 원래대로 되돌리면 끝입니다. 이걸 변이 테스트(mutation testing)라고 부르고, 심은 버그를 변이(mutant)라고 부릅니다.
// mutate.mjs — 문자열 하나를 바꿔 버그를 심는다
import { readFileSync, writeFileSync } from 'node:fs';
const [file, from, to] = process.argv.slice(2);
const src = readFileSync(file, 'utf8');
if (!src.includes(from)) { console.error('MUTATION_TARGET_MISSING'); process.exit(1); }
writeFileSync(file, src.replace(from, to));>=를 >로, .default를 .catch로, a.or(b)를 b.or(a)로. 이렇게 바꿨는데 테스트가 여전히 초록이면, 그 줄은 사실상 테스트가 없는 줄입니다.
캐릭터 꾸미기 옵션 13개(머리 모양, 상의 색 같은 것)를 검증하는 zod 스키마가 있었습니다. 테스트에는 "모르는 값은 거부한다" 케이스가 있었는데, 13개 중 4개 필드에만 닿아 있었습니다.
감사 에이전트가 변이 34개를 심자 11개가 살아남았고, 그중 9개가 나머지 필드에서 .default를 .catch로 바꾼 것이었습니다. .catch는 잘못된 값을 거부하지 않고 기본값으로 바꿔 버립니다. 엉뚱한 색이 들어와도 조용히 통과한다는 뜻인데, 테스트는 초록이었습니다.
같은 세션에서 태스크 12개 중 4개가 이런 식으로 반려됐습니다. 모양은 매번 같았습니다. N개 중 일부만 봤다. N만 달랐습니다.
셀카를 받아 특징만 뽑고, 사진은 어디에도 남기지 않는 API였습니다. 테스트 이름도 privacy.test.ts. 초록불이었습니다.
그런데 "아무것도 안 샌다"를 확인하는 테스트는 위험합니다. 아무것도 안 보고 있어도 통과하니까요. 그래서 핸들러에 사진을 파일로 쓰는 코드를 직접 심어 봤습니다.
x12c-fsp-open old privacy draft: red=[] green=1
-rw-r--r--@ 1 ... 34 Oct 10 04:40 .claude/tmp/t6b-iso/leak-probe.bin실패한 테스트는 0개(red=[]). 그리고 바로 옆에 사진이 담긴 leak-probe.bin이 생겨 있었습니다. 원인은 fs 모듈을 가짜로 바꿀 때 일부 경로만 바꾸고, fs/promises의 파일 핸들은 진짜로 남겨 둔 것이었습니다.
고친 방법은 이렇습니다. 사진이 샐 수 있는 출구(파일, 로그, 응답)를 전부 적고, 출구마다 "일부러 흘린 걸 잡는지" 확인하는 테스트를 하나씩 두었습니다.
모바일 청첩장의 D-day 계산은 "무조건 서울 기준"이 규칙이었습니다. 감사 에이전트가 서버 로컬 시간을 쓰는 버그 4개를 심었는데, 테스트 5개가 전부 통과했습니다. 이유는 허무했습니다. 제 노트북 시간대가 서울이었으니까요. 로컬 시간을 잘못 쓰는 코드가, 이 컴퓨터에서는 정답을 냈습니다. 뉴욕 서버였다면 8월 1일 결혼식이 7월 달력에 나왔을 겁니다. 해결은 테스트 안에서 시간대를 UTC, 뉴욕, UTC+14로 바꿔 같은 순간을 다시 돌리는 것이었습니다.
비슷한 일이 데이터에서도 났습니다. 사진 업로드 대기열 테스트의 id가 0, 1, 2라서 배열 위치와 같았고, 정렬 순서도 0, 1, 2라서 화면 순번과 같았습니다. 코드가 엉뚱한 필드를 읽어도 값이 우연히 같으니 테스트는 맞는 코드와 틀린 코드를 구분하지 못했습니다. 고친 방법은 단순합니다. 일부러 어긋나는 값을 씁니다. 예를 들어 id는 101, 205, 309처럼 위치와 겹치지 않게요.
여기까지 읽으면 "그럼 AI에게 변이 테스트까지 시키면 되겠네" 싶습니다. 저도 그랬습니다.
Rust로 만든 판정 함수에서, 계획서에 적힌 변이 5개를 전부 잡았습니다. 자신만만하게 독립 감사를 붙였더니, 감사가 한 줄짜리 순서 뒤집기를 심었습니다.
reason = reason.or(r) → reason = r.or(reason)
test result: ok. 155 passed; 0 failed155개가 전부 통과했습니다. 지도 SDK 로더에서는 워커가 직접 만든 변이 16개가 전부 잡혔는데, 처음 보는 감사가 만든 변이는 26개 중 2개가 살아남았습니다.
이유는 같습니다. 테스트를 쓴 쪽이 변이를 만들면, 자기가 상상한 실패만 심습니다. 테스트에 없는 자리는 변이 목록에도 없습니다. 그래서 지금은 테스트를 쓴 세션과 컨텍스트를 공유하지 않는 감사 에이전트가 버그를 심게 합니다.
덧붙여 하나. 변이를 심는 도구도 의심해야 합니다. Python에서 같은 1초 안에 같은 크기로 파일을 두 번 바꾸면, 캐시된 옛 바이트코드가 그대로 돌아서 "버그가 살아남은 것처럼" 보인 적도 있습니다.
AI가 "테스트 다 통과했습니다"라고 하면, 이것만 해 보세요.
핵심 줄마다 버그를 하나씩 심어 본다. 초록으로 남는 줄이 테스트가 없는 줄입니다.
"없다"를 확인하는 테스트에는, 일부러 흘린 걸 잡는지 보는 테스트를 짝으로 둔다.
시간대·언어 설정을 바꿔 한 번 더 돌린다.
테스트 데이터는 일부러 어긋나게 만든다. id와 위치가 같으면 안 됩니다.
버그는 테스트를 쓰지 않은 쪽이 심는다.
저는 5번을 Claude Code 플러그인 dev-loop에 넣어 두었습니다. 워커가 "끝났다"고 말하기 전에, 테스트를 쓴 세션과 컨텍스트를 공유하지 않는 test-quality-auditor 에이전트가 "이 테스트가 정말 바뀐 동작을 검증하는지"를 따로 판정합니다. 위 사례에서 버그를 심어 구멍을 찾은 것도 대부분 이 감사 단계였습니다.
/plugin marketplace add choiyounggi/dev-loop
/plugin install dev-loop@dev-loop👉 저장소: github.com/choiyounggi/dev-loop (MIT)
플러그인 없이도 1~4번은 오늘 바로 해 볼 수 있습니다. 위에 붙인 mutate.mjs 몇 줄이면 충분합니다.
관련 글: