변이 테스트로 확인한 «이미 테스트가 있다» — 주석을 지우기 전에 코드를 뒤집어 봤습니다
코드 주석이 말하던 불변식을 «기존 테스트가 이미 고정한다»며 주석만 지우기 전에, 변이 테스트처럼 코드를 일부러 뒤집어 그 테스트가 정말 실패하는지 확인했습니다. 이름이 그럴듯한 테스트가 변이를 놓치는 일이 단계마다 나왔고, 원인은 거의 늘 픽스처와 가짜 객체의 우연이었습니다.
앞 글 「코드 주석은 다섯 종류만 남깁니다」에서 주석 4만 8천여 개를 판정한 이야기를 적었습니다. 그때는 코드로 옮긴 뒤에 지우기로 한 주석(MUST KILL)을 아직 하나도 옮기지 않았습니다. 그 글에 이런 문장을 적었습니다. «test 종류 675개는 기존 테스트가 그 불변식을 이미 고정하고 있는지부터 찾습니다. 있으면 테스트를 새로 쓰지 않고 주석만 지운 뒤, 그 테스트의 이름을 근거로 남깁니다.» 이 글은 그 «있으면»이 생각보다 자주 거짓이었다는 이야기입니다.
그사이 주석 일괄 정리는 머지되었습니다. JS/TS 주석 45,042개와 비JS 주석 2,346줄을 지웠고, MUST KILL 은 리뷰에서 50건이 더해져 979건으로 남겨 두었습니다. 그 뒤 여섯 번의 후속 작업으로 979건 중 501건을 처리했습니다(10월 29일 기준).
먼저 밝혀 둘 것이 있습니다. 이번 작업은 변이 도구(예를 들면 Stryker)로 스위트 전체의 변이 점수를 낸 것이 아닙니다. 주석 한 줄이 말하던 불변식마다 그것을 깨는 변이를 손으로 골라 넣어 본 표적 변이 시험입니다. 그래서 표적 밖에 있는 약한 테스트는 이번에 보지 못했고, 넣어 본 변이의 전체 합계도 세지 않았습니다. 변이를 넣고 돌리고 원복하는 일은 단계마다 맡긴 코딩 에이전트가 했지만, 이 글에서 다룰 것은 그 방법과 결과입니다.
변이 테스트 — 커버리지가 묻지 않는 질문
변이 테스트(mutation testing)는 코드에 작은 결함을 일부러 넣고, 테스트 스위트가 그것을 잡는지 보는 방법입니다. 넣은 결함을 변이(mutant)라 부르고, 테스트가 실패하면 변이를 «죽였다», 통과하면 변이가 «살아남았다»고 말합니다. 대표적인 출발점으로 꼽히는 것은 1978년 DeMillo·Lipton·Sayward 가 IEEE Computer 에 실은 「Hints on Test Data Selection: Help for the Practicing Programmer」입니다. 이 방법의 기본 가정은 결합 효과(coupling effect)입니다. 작은 결함을 잡는 테스트는 그 결함들이 겹친 더 큰 결함도 잡는다는 가정입니다.
커버리지와는 묻는 것이 다릅니다. 커버리지는 «그 줄이 실행됐는가»만 말합니다. 그 줄이 틀렸을 때 테스트가 실패하는지는 말하지 않습니다. 변이 시험이 묻는 것은 뒤의 질문이고, «이 불변식은 기존 테스트가 고정한다»는 주장도 사실은 뒤의 질문에 대한 답이어야 합니다.
살아남은 변이는 보통 두 갈래로 나눕니다. 하나는 테스트가 약해서 결함을 보지 못한 경우입니다. 다른 하나는 등가 변이(equivalent mutant)로, 코드는 바뀌었지만 관찰 가능한 동작이 원본과 같아서 어떤 테스트로도 죽일 수 없는 경우입니다. 어떤 변이가 등가인지 판정하는 일은 일반적으로 결정 불가능한 문제로 알려져 있습니다. 이번에 살아남은 변이를 하나씩 들여다보니, 많은 경우가 이 두 갈래 어디에도 딱 들어맞지 않았습니다.
처리 규칙 — 변이 테스트로 뒤집어 보고, 실패를 본 뒤에만 근거로 삼습니다
MUST KILL 을 옮기는 규칙은 레포 문서에 이렇게 적었습니다. test 종류는 먼저 기존 테스트가 그 불변식을 이미 고정하는지 찾습니다. 있으면 코드를 일부러 뒤집어 그 테스트가 실제로 실패하는지 확인한 뒤 원복하고, 테스트를 더하지 않고 주석만 지웁니다. 없으면 그 불변식만 겨누는 가장 작은 테스트를 쓰고, 깨진 코드에서 먼저 실패하는지 확인합니다. 어느 경우든 동작은 바뀌지 않아야 합니다.
주석 한 줄이 말하던 불변식마다
1. 그 불변식을 정확히 깨는 변이를 고른다
2. 코드에 넣고 해당 테스트를 돌린다 → 실패해야 근거가 된다
3. 원복한다
4. 원복한 파일이 원본과 내용이 같은지 스크립트로 단언한다
501건의 처리 결과는 다음과 같습니다.
| 처리 | 건수 | 내용 |
|---|---|---|
| 기존 테스트로 갈음 | 209 | 테스트를 더하지 않고 주석만 지움 |
| 새 테스트로 고정 | 187 | 테스트를 새로 쓰거나 보강 |
| 코드로 옮김 | 71 | 이름·추출·타입 |
| 죽은 코드 제거 | 1 | — |
| 결정 대기 | 33 | 동작을 바꿔야 고칠 수 있어 보류 |
예측 — 이름이 불변식을 말하면 싼 경로일 것이라 봤습니다
처음에는 «기존 테스트로 갈음»이 가장 싼 경로라고 봤습니다. 테스트 이름이 불변식을 그대로 말하는 경우가 많았기 때문입니다. «무상이 모자라면 운영자 지급을 먼저 쓰고 유상은 마지막에 손댄다» 같은 이름은, 지우려는 주석과 거의 같은 말을 하고 있었습니다. 이런 테스트를 찾으면 이름을 근거로 남기고 주석을 지우면 될 것이라 예상했고, 뒤집기는 형식적인 확인에 가까울 것이라 여겼습니다.
결과 — 단계마다 살아남은 변이가 나왔습니다
실제로는 단계마다 처음 고른 변이 가운데 몇 개가 살아남았습니다.
| 단계 | 넣은 변이 | 처음에 살아남은 변이 |
|---|---|---|
| AI 기능·관리 모듈 (34건) | 약 80종 | 9종 |
| 크레딧 모듈 (32건) | 런타임 74종 + 타입 6종 | 런타임 4종 (타입 6종은 모두 tsc 에서 실패) |
| 인증 모듈 | 56종 | 0종 |
| 주식·세무 모듈 (39건) | 48종 | 2종 |
인증 모듈처럼 하나도 살아남지 않은 단계도 있었습니다. 살아남은 변이는 전부 새 테스트나 픽스처 보강으로 잡았고, 잡은 뒤 같은 변이가 실패하는 것을 다시 확인했습니다.
살아남은 쪽을 들여다보면 공통점이 있었습니다. 테스트 이름은 불변식을 정확히 말하고 있었는데, 테스트가 고른 입력값이 그 불변식을 시험하지 않았습니다. 코드는 일반적으로는 다르게 동작하지만, 그 입력에서만큼은 우연히 같은 값을 냈습니다. 단언이 틀린 것도 아니고, 다른 입력을 넣으면 금방 차이가 나니 등가 변이도 아니었습니다. 저는 이것을 «이 픽스처에서만 등가»인 변이라고 부르게 되었습니다.
시가 합이 100 이었습니다 — 변이를 바꿀 것이 아니라 픽스처를 고쳐야 했습니다
가장 선명했던 사례는 포트폴리오 위험 계산이었습니다. 시가 비중을 구하는 인라인 식에 설명 주석이 붙어 있어서, 식을 이름 있는 함수로 뽑았습니다.
export function marketValueWeights(marketValues: readonly number[]): number[] {
const total = marketValues.reduce((sum, value) => sum + (value || 0), 0);
return marketValues.map((value) => (total > 0 ? value / total : 0));
}
이 함수의 불변식은 «각 시가를 시가 총합으로 나눈다»입니다. 함수를 뽑을 때 먼저 근거로 묶은 것은 상위의 고정 벡터 테스트였습니다. 포트폴리오의 60일 변동성이 0.8221174559670343 인지 toBe 로 정확히 비교하는 테스트입니다. 분모를 깨는 변이로는 total 대신 상수 100 으로 나누는 것을 골랐습니다.
return marketValues.map((value) => (total > 0 ? value / 100 : 0));
이 변이는 고정 벡터 테스트에서 살아남았습니다. 고정 벡터의 시가 합도 100 이었기 때문입니다. 총합으로 나누든 100 으로 나누든 같은 비중이 나오니, 그 위의 변동성도 같은 값이 나옵니다.
부끄럽지만 그 단계에서는 여기서 멈추지 않고 변이를 바꿨습니다. 분모에 1을 더하는 변이와 0 가드를 빼는 변이로 바꿔 넣자 고정 벡터 테스트가 실패했고, 그것을 확인하고 넘어갔습니다. 픽스처는 그대로 두었습니다.
같은 사정이 단위 테스트에도 있었습니다.
expect(marketValueWeights([75, 25, 0])).toEqual([0.75, 0.25, 0]);
이 픽스처의 합도 75 + 25 + 0 = 100 입니다. 테스트는 «비중을 구한다»를 말하고 있었지만, 데이터는 그 동작을 «백분율을 소수로 바꾼다»와 구별하지 못했습니다. 이 점은 그 뒤 리뷰 첫 라운드가 짚었고, 그래서 합이 100 이 아닌 값으로 바꿨습니다.
expect(marketValueWeights([60, 20, 0, 120])).toEqual([0.3, 0.1, 0, 0.6]);
합이 200 이 되자 /100 변이는 0.6, 0.2, 0, 1.2 를 내고 실패합니다.
돌이켜 보면 변이를 갈아 끼운 것은 확인을 피해 가는 길이었습니다. 살아남은 변이는 «이 픽스처로는 분모가 총합인지 시험할 수 없다»는 사실을 정확히 알려 주고 있었습니다. 그 신호를 받고 죽는 변이를 찾아 바꾸면 실패는 볼 수 있지만, 처음 물었던 불변식은 여전히 아무것도 시험하지 않습니다. 고쳐야 했던 것은 변이가 아니라 픽스처였습니다.
가짜 DB 가 자기 자신을 트랜잭션으로 넘겼습니다
두 번째로 오래 들여다본 것은 크레딧 모듈의 트랜잭션 테스트였습니다. 불변식은 결제가 확정될 때 두 종류의 크레딧 지급이 같은 트랜잭션 안에서 일어나야 한다는 것입니다.
이를 고정한다던 테스트는 두 지급이 받은 트랜잭션 객체가 같은지를 봤습니다. 그런데 테스트의 가짜 DB 는 Jest 목으로 transaction 을 이렇게 흉내 내고 있었습니다.
transaction: jest.fn(async (cb) => cb(db)),
콜백에 트랜잭션 객체 대신 db 자기 자신을 넘깁니다. 그래서 한 지급을 트랜잭션 밖으로, 즉 커밋 뒤에 db 로 직접 부르도록 옮기는 변이를 넣어도 «같은 객체» 단언은 통과했습니다. 트랜잭션 안의 지급이 받은 것도 db 였고, 밖으로 나간 지급이 쓴 것도 db 였으니까요.
고친 것은 두 군데입니다.
transaction: jest.fn(async (cb) => cb({ ...dbShape })),
// ...
expect(aiTx).toBe(minutesTx);
expect(minutesTx).not.toBe(f.db);
가짜 transaction 은 이제 콜백에 db 와 모양은 같지만 다른 객체를 넘깁니다. 한 지급만 밖으로 옮기면 두 지급이 받은 객체가 달라져 첫 단언이 실패합니다. 두 지급을 함께 밖으로 옮기면 둘 다 db 를 받아 첫 단언은 통과하지만, 두 번째 단언이 실패합니다. 이 두 줄이 함께 있어야 «같은 트랜잭션 안»이라는 이름을 데이터가 실제로 시험하게 됩니다.
원래의 가짜는 트랜잭션을 지원하는 척은 했지만, 안과 밖을 가르는 경계는 흉내 내지 않았습니다. 가짜 객체가 무엇을 흉내 내고 무엇을 생략했는지가 곧 그 테스트가 볼 수 있는 범위라는 생각이 들었습니다.
유형별로 본 «이 픽스처에서만 등가»
상한 아래의 크기
AI 호출의 응답 본문을 기록할 때 200,000자를 넘으면 잘라서 저장합니다. 기존 테스트의 픽스처 본문은 5,000자였습니다. 자르는 코드를 지워도 5,000자는 상한 아래라 잘릴 것이 없으니 테스트는 통과했고, 이 자리에서 변이 2종이 살아남았습니다. 픽스처를 «상한 + 1,000자», 즉 AI_CALL_BODY_MAX + 1,000 으로 상수에서 파생해 만들었습니다. 상한이 바뀌어도 픽스처가 함께 따라갑니다.
조건을 버리는 가짜
총 잔액을 SUM 할 때는 무상·운영자 지급·유상 세 칸을 모두 더해야 합니다. 운영자 칸을 빼는 변이가 살아남았는데, 테스트의 가짜가 이미 합산이 끝난 값을 돌려주고 있었기 때문입니다. 실제 SQL 을 렌더해 세 칸을 모두 더하는지 문자열로 고정했습니다.
where 절도 마찬가지였습니다. 여러 UPDATE 에 «이미 처리된 행은 건드리지 않는다» 같은 가드 조건이 있었는데, 가드를 지우는 변이가 살아남았습니다. 가짜 DB 가 where 인자를 받아만 두고 평가하지 않았습니다. 해당 UPDATE 의 where 를 Drizzle 의 MySQL dialect 로 SQL 문자열과 파라미터로 렌더해 단언했고, 한 모듈에서만 이런 자리 네 곳을 고정했습니다.
세무 연 합계의 «그해 1월 1일 이상, 다음 해 1월 1일 미만» 조건도 목이 인자를 버리고 있어, 그 조건을 고정하는 테스트가 없었습니다. 이 자리는 리뷰 첫 라운드가 짚었습니다. 조건을 렌더해 revenue_date >= $1 and revenue_date < $2 와 ['2026-01-01', '2027-01-01'] 을 단언하자, 반기 창으로 바꾸는 변이가 실패했습니다. 조회 목이 필터와 무관하게 null 을 돌려주던 자리에서는 «공개된 글만» 조건을 빼는 변이가 살아남을 수 있어서, 조회 필터를 단언하는 한 줄을 더했습니다. 조건을 받아 버리는 목은 그 조건에 대해 아무 말도 하지 않습니다.
스위트 전체가 통과했습니다
테스트가 많아도 사정은 같았습니다. 가계부 분개 적용기에서 잠금을 차지하는(claim) 단계를 두 번째 루프로 합치는 변이를 넣어도 스위트 259건이 모두 통과했습니다. 내장 규칙의 순서를 역순으로 뒤집어도 262건이 모두 통과했습니다. 새 테스트 4개로 고정했습니다.
PR 리뷰 작업 큐에서는 세대 한정 취소의 $or 필터를 지워도, 실행 중인 작업을 15분 뒤 unknown 이 아니라 queued 로 되돌려도 148건이 모두 통과했습니다. 백필 작업에서는 완료 마커를 마지막 페이지(hasNext=false) 확인 없이 찍는 변이가 기존 11건을 모두 통과해, «예외 없이 페이지 상한에서 멈춘 경우에도 마커를 찍지 않는다»는 테스트를 더해 잡았습니다. 테스트가 많다는 것과 그 불변식을 시험한다는 것은 별개의 일이었습니다.
결함이 둘인 실패 픽스처
예산 장부의 월 상한 가드에서는 조금 다른 모양의 우연을 봤습니다. 손상 장부 픽스처에 시도 기록 누락이라는 다른 결함이 함께 들어 있었습니다. 상한 가드를 지워도 뒤의 다른 검사가 그 결함을 보고 대신 거부했고, 테스트는 «거부됐다»만 봤기 때문에 통과했습니다. 회계는 맞는데 상한만 넘는 장부로 새 테스트를 더했습니다. 실패 픽스처에는 결함을 하나만 넣어야 합니다.
이름과 다른 단언
«만료 표시는 트랜잭션 안에서, 저장소 파일 파기는 커밋 뒤»라는 이름의 테스트는 실제로 순서를 단언하지 않았습니다. 새 테스트로 순서를 고정했는데, 파기를 기다리지 않고 응답하는(fire-and-forget) 변이는 새로 쓴 await 테스트도 처음엔 잡지 못했습니다. «파기가 끝나기 전에는 다음 외부 호출(소셜 연결 끊기)을 부르지 않는다»는 단언을 더하고 나서야 직접 잡혔습니다. 새로 쓴 테스트도 뒤집어 보기 전까지는 근거가 아니었습니다.
죽은 변이도 왜 죽었는지 봐야 했습니다
살아남은 변이만 의심할 일은 아니었습니다. «락을 잡은 뒤에 다시 읽는다»는 순서를 뒤집는 변이는 처음부터 테스트를 실패시켰습니다. 그런데 실패한 이유를 보니 불변식 때문이 아니었습니다. 테스트가 가짜 DB 의 select 응답 큐 순서에 묶여 있어서, 조회 순서가 바뀌자 다른 응답을 받아 실패한 것이었습니다.
가짜 DB 에 «락 아래에서 읽은 것»을 남기는 로그(readsUnderLock)를 두고, 순서를 직접 단언하도록 바꿨습니다. 변이가 죽었다는 것은 테스트가 무언가에 반응했다는 뜻이지, 그 불변식을 지킨다는 뜻은 아니었습니다. 왜 실패했는지까지 봐야 그 실패를 근거로 쓸 수 있습니다.
진짜 등가 변이 — 지운 주석이 이미 틀려 있었습니다
등가 변이도 한 번 만났습니다. 하루 사용량 카운터의 count 칸에 기본값(default: 0)을 두지 말라는 주석이 있었고, 이유는 «서버가 거부한다»였습니다. 그런데 default: 0 을 넣는 변이가 테스트를 깨지 않았습니다.
조사해 보니 Mongoose 8.24 에서는 upsert 의 setDefaultsOnInsert 가 $inc 로 올리는 경로를 건너뛰어서, 기본값을 넣어도 충돌이 나지 않았습니다. 이 변이는 지금의 라이브러리에서 관찰 가능한 동작이 원본과 같은, 진짜 등가 변이였습니다. 그리고 주석의 «서버가 거부한다»는 지금의 동작이 아니었습니다.
남긴 것은 실제 Mongo 에 대고 «발송하고 카운터를 1 올린다(count 1)»를 단언하는 기존 테스트입니다. 미래의 Mongoose 가 그 경로에 기본값을 끼워 넣으면 기록 함수가 그 오류를 경고로 삼키고, 이 count 1 단언이 실패합니다.
앞 글의 mysql2 사례와 같은 결이었습니다. 주석은 실행되지 않으니 틀려도 실패하지 않습니다. 살아남은 변이가 진짜 등가라면, 그것은 테스트가 아니라 주석, 혹은 우리가 가진 믿음이 틀렸다는 신호일 수 있습니다. 테스트의 빈틈을 찾으려던 변이 시험이 주석의 거짓까지 드러낸 셈입니다.
정리 — «테스트가 있다»를 근거로 쓰기 전에 변이 테스트로 뒤집어 봅니다
이번 작업에서 얻은 규칙을 일반화해 적으면 이렇습니다.
- «테스트가 있다»는 근거로 쓰기 전에 코드를 뒤집어 봅니다. 실패를 본 뒤에만 근거입니다.
- 변이는 불변식을 정확히 깨는 것으로 고르고, 살아남으면 바꾸지 않습니다. 처음 고른 변이가 살아남으면 «이 픽스처에서 등가인가»를 보고, 그렇다면 픽스처를 고칩니다. 죽는 변이로 갈아 끼우는 것은 확인이 아니라 회피입니다.
- 픽스처 값은 특별한 수를 피합니다. 합 100, 1, 0, 상한 아래의 크기, 대칭이 되는 원소 2개가 그런 수입니다. 상한은 상수에서 파생해 넘깁니다.
- 가짜 DB 와 목은 조건을 버리면 안 됩니다. 평가하지 못하면 최소한 조건을 렌더해 단언합니다. 트랜잭션 가짜는 db 와 다른 객체를 넘깁니다.
- 실패 픽스처에는 결함을 하나만 넣습니다. 둘이면 다른 검사가 대신 거부해 줍니다.
- 죽은 변이도 왜 죽었는지 봅니다.
- 살아남은 변이가 진짜 등가라면, 그것은 테스트가 아니라 주석이나 믿음이 틀렸다는 신호일 수 있습니다.
비용 쪽도 정직하게 적어 둡니다. 전 스위트에 변이 도구를 돌린 것이 아니어서, 표적 밖의 약한 테스트는 이번 작업에서 보지 못했습니다. 넣은 변이의 전체 합계도 세지 않았습니다. 979건 중 471건은 아직 남아 있고, 레포 문서로 인계해 두었습니다.
테스트 이름은 불변식을 말하지만, 그 불변식을 실제로 시험하는 것은 데이터입니다. 테스트 신뢰도도 결국 이름이 아니라 데이터가 정합니다. 둘 사이의 거리는 이름만 읽어서는 보이지 않았고, 코드를 뒤집어 봐야 보였습니다. 주석을 테스트로 옮긴다는 것은 결국 주석이 하던 말을 이름이 아니라 데이터가 하게 만드는 일이었다는 생각이 들었습니다.
관련 글
결함 주입으로 방어선 24곳을 망가뜨려 봤습니다 — 방어가 두 겹이면 테스트는 한 겹만 잽니다
테스트가 전부 초록인 서버에서 방어선 24곳에 일부러 결함을 주입했습니다. 첫 시도에 23곳이 잡혔지만 허용 목록 조인을 지운 결함 하나는 초록으로 남았습니다. 두 겹의 방어가 서로를 가렸기 때문입니다. 한 겹만 남는 상황을 만들고 전제 단언으로 다시 잰 기록입니다.
주석이 거짓말을 하고 있었습니다 — 이스케이프가 실제로 막는 범위
경로를 감싸는 함수가 콜론을 이스케이프하고 있었습니다. 그래서 윈도우 경로를 막아준다고 주석에 적혀 있었는데, 재보니 콜론만 여전히 실패했습니다. 이스케이프하는 것과 지켜주는 것은 다른 말이었습니다.
코드 주석은 다섯 종류만 남깁니다 — pstack Comment Sicko 를 모노레포에 적용하며
pstack 의 Comment Sicko 규칙으로 JS/TS 주석 4만 8천여 개를 지우기 전에 1차 판정했더니 95% 가 지울 대상이었습니다. 남긴 외부 제약에는 같은 mysql2 동작을 정반대로 말하는 주석 두 갈래가 있었고, 아무것도 실패하지 않았습니다.