Skip to content

Instantly share code, notes, and snippets.

@BumgeunSong
Created July 30, 2026 13:46
Show Gist options
  • Select an option

  • Save BumgeunSong/b6da66fb481a6a79b1ef481b38bb0ab3 to your computer and use it in GitHub Desktop.

Select an option

Save BumgeunSong/b6da66fb481a6a79b1ef481b38bb0ab3 to your computer and use it in GitHub Desktop.
AI 시대의 개발자는 검증을 설계한다 (5) - Test를 검증하는 변형

Mutation 검증 — Agent Skill & Commit Gate Hook

통과하는 테스트도 false green일 수 있습니다. 지킨다고 주장하는 동작이 깨져도 여전히 초록불인 테스트요. 커버리지는 이걸 못 잡습니다. 코드가 실행됐다는 것만 말할 뿐, 버그를 막는지는 말해주지 않으니까요.

확인하는 방법은 단순합니다. 버그를 심어보면 됩니다. 갑옷이 화살을 막는지 알려면 실제로 쏴봐야 하는 것처럼요.

문제는 이게 귀찮다는 것이었습니다. 전통적인 mutation testing은 변형을 전수 생성해서 수천 번 테스트를 돌리고, 살아남은 변형을 사람이 하나씩 열어봐야 했습니다. 그래서 "테스트 품질 측정법 중 최고"라는 평가를 받으면서도 실무에서는 거의 안 쓰였죠.

AI는 전수로 뿌리지 않습니다. 맥락을 보고 진짜 위험한 3~5발만 골라 쏠 수 있습니다. 비용이 내려가니 안 할 이유가 사라졌습니다.

여기 있는 건 그 절차를 에이전트에게 위임하기 위한 Skill과, 잊지 않게 만드는 Hook입니다.

배경 글: AI 시대의 개발자는 검증을 설계한다 (5) — Test를 검증하는 Mutation

파일

파일 역할
SKILL.md Agent Skill. 관문 → 설계 → 실행 → 판정 → 종결 + 부록 2개
mutation_gate.py 커밋 게이트 훅. 검증 기록이 없으면 커밋을 막는다

설치

mkdir -p .claude/skills/mutation-verify .claude/hooks
 
curl -o .claude/skills/mutation-verify/SKILL.md <SKILL.md raw url>
curl -o .claude/hooks/mutation_gate.py       <mutation_gate.py raw url>
chmod +x .claude/hooks/mutation_gate.py

.claude/settings.json에 훅을 등록합니다.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "python3 .claude/hooks/mutation_gate.py" }
        ]
      }
    ]
  }
}

테스트 파일 패턴이 프로젝트 관례와 다르면 mutation_gate.pyTEST_GLOBS를 편집하거나 환경변수로 덮어씁니다.

export MUTATION_GATE_TEST_GLOBS="**/*.test.ts,**/*.spec.ts"
#!/usr/bin/env python3
"""
Mutation 검증 게이트 — PreToolUse / Bash 훅.
커밋에 테스트 파일이 스테이징되어 있으면, 커밋 메시지에 mutation 검증 이행 기록
(Mutation-Verified trailer)이 있는지 확인한다. 없으면 커밋을 막고 무엇을 해야 하는지
알려준다.
왜 필요한가
통과하는 테스트도 false green일 수 있다 — 지킨다고 주장하는 동작이 깨져도 여전히
초록불인 테스트. mutation 검증은 이걸 걸러내는 절차이고, 이 훅은 그 절차를 잊지
않게 만드는 장치다.
이 훅이 하지 않는 것
mutation을 직접 실행하지 않는다. 검증의 '질'도 판정하지 않는다. 커밋 메시지에
한 줄이 있는지만 본다. 즉 이건 증명이 아니라 마찰이다. 결정적으로 검사할 수 있는
지점까지만 결정적으로 검사하고, 나머지는 사람과 스킬에 맡긴다.
통과하는 두 가지 형태
Mutation-Verified: killed 3 / survived 1(아무도 안 지킴) by mutation-verify
Mutation-Verified: skip(no assertion change) by mutation-verify
단언·케이스를 안 건드린 순수 리팩터(이름·서식·이동)는 검증할 새 보장이 없으므로
skip(...) 을 쓴다.
발동하지 않는 경우 (전부 fail-open)
- git commit 이 아닌 명령
- 스테이징된 테스트 파일이 없음
- 메시지를 명령 문자열에서 읽을 수 없는 형태 (-F/--file, 편집기 사용 등)
- git 저장소가 아니거나 git 호출 실패
설계 원칙
잘못 막는 훅은 없는 훅보다 나쁘다. 판단이 애매하면 항상 통과시킨다.
의식적 우회
명령 앞에 ALLOW_NO_MUTATION=1 을 붙인다.
설치
cp mutation_gate.py .claude/hooks/
chmod +x .claude/hooks/mutation_gate.py
.claude/settings.json:
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [
{"type": "command", "command": "python3 .claude/hooks/mutation_gate.py"}]}]}}
author : bumgeun-song
license: MIT
"""
from __future__ import annotations
import fnmatch
import json
import os
import re
import shlex
import subprocess
import sys
# ---------------------------------------------------------------------------
# 설정 — 프로젝트에 맞게 조정
# ---------------------------------------------------------------------------
# 이 패턴에 걸리는 파일이 스테이징되어 있을 때만 훅이 관여한다.
TEST_GLOBS = [
"**/*.test.ts", "**/*.test.tsx", "**/*.test.js", "**/*.test.jsx",
"**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js", "**/*.spec.jsx",
"**/*.e2e.ts", "**/*.e2e.js",
"**/test_*.py", "**/*_test.py", "**/*_test.go",
]
# 검증 절차가 적힌 스킬 이름. 거부 메시지에 그대로 노출된다.
SKILL_NAME = "mutation-verify"
TRAILER_KEY = "Mutation-Verified:"
ESCAPE_HATCH = re.compile(r"\bALLOW_NO_MUTATION=(?:1|true|TRUE|yes)\b")
# 환경변수로 덮어쓸 수 있게 해둔다 (콤마 구분).
if os.environ.get("MUTATION_GATE_TEST_GLOBS"):
TEST_GLOBS = [g.strip() for g in
os.environ["MUTATION_GATE_TEST_GLOBS"].split(",") if g.strip()]
# ---------------------------------------------------------------------------
# 셸 명령 파싱
# ---------------------------------------------------------------------------
_GIT_COMMIT = re.compile(r"^((?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*)git\s+commit(?:\s|$)")
_LEADING_KEYWORD = re.compile(r"^(?:if|then|else|elif|do|while|until|for|!|\$\()\s+")
_EXTERNAL_FLAGS = {"-F", "--file", "-C", "--reuse-message"}
def split_pipeline(s: str) -> list[str]:
"""따옴표를 존중하면서 ; | && || 개행으로 명령을 분절한다.
단순 부분문자열 검색(`"git commit" in cmd`)으로 판단하면 안 된다.
커밋 메시지 본문에 'git commit'이라는 글자가 들어 있는 경우처럼,
엉뚱한 명령을 커밋으로 오인하게 된다.
"""
parts: list[str] = []
buf: list[str] = []
in_single = in_double = False
i = 0
while i < len(s):
ch = s[i]
nxt = s[i + 1] if i + 1 < len(s) else ""
if ch == "\\" and i + 1 < len(s):
buf.append(ch)
buf.append(s[i + 1])
i += 2
continue
if ch == '"' and not in_single:
in_double = not in_double
elif ch == "'" and not in_double:
in_single = not in_single
if not in_single and not in_double:
if (ch == "&" and nxt == "&") or (ch == "|" and nxt == "|"):
parts.append("".join(buf))
buf = []
i += 2
continue
if ch in ";|\n":
parts.append("".join(buf))
buf = []
i += 1
continue
buf.append(ch)
i += 1
parts.append("".join(buf))
return [p.strip() for p in parts if p.strip()]
def classify_command(cmd: str) -> str:
"""'skip' | 'external' | 'inline' 중 하나를 돌려준다.
skip — 검사 대상이 아니다 (커밋이 아니거나 우회가 걸렸다)
external — 커밋이지만 메시지를 명령 문자열에서 읽을 수 없다 → fail-open
inline — 메시지가 명령 안에 있다 → 검사 가능
"""
for segment in split_pipeline(cmd):
segment = _LEADING_KEYWORD.sub("", segment)
match = _GIT_COMMIT.match(segment)
if not match:
continue
if ESCAPE_HATCH.search(match.group(1) or ""):
return "skip"
try:
tokens = shlex.split(segment)
except ValueError:
tokens = []
has_inline = bool(re.search(r"(^|\s)(-m\b|--message)", segment)) or "<<" in segment
has_external = any(
t in _EXTERNAL_FLAGS
or t.startswith("--file=")
or t.startswith("--reuse-message=")
for t in tokens
)
if has_inline:
return "inline"
if has_external or "--amend" in tokens:
return "external"
# 맨몸 `git commit` — 편집기가 열린다. 메시지를 볼 수 없다.
return "external"
return "skip"
# ---------------------------------------------------------------------------
# git 조회
# ---------------------------------------------------------------------------
def staged_test_files() -> list[str]:
"""스테이징된 파일 중 테스트 패턴에 걸리는 것들. 실패하면 빈 리스트(=통과)."""
try:
subprocess.run(
["git", "rev-parse", "--git-dir"],
check=True, capture_output=True, timeout=5,
)
out = subprocess.run(
["git", "-c", "core.quotepath=false",
"diff", "--cached", "--name-only", "--diff-filter=d"],
check=True, capture_output=True, text=True, timeout=5,
).stdout
except Exception:
return []
hits = []
for path in (line.strip() for line in out.splitlines()):
if not path:
continue
name = os.path.basename(path)
if any(fnmatch.fnmatch(path, g) or fnmatch.fnmatch(name, g.rsplit("/", 1)[-1])
for g in TEST_GLOBS):
hits.append(path)
return hits
# ---------------------------------------------------------------------------
# 출력
# ---------------------------------------------------------------------------
def allow() -> None:
sys.exit(0)
def deny(reason: str) -> None:
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}, ensure_ascii=False))
sys.exit(0)
def build_reason(files: list[str]) -> str:
listed = "\n".join(f" - {f}" for f in files)
return f"""이 커밋에 테스트 파일이 있는데 mutation 검증 기록이 없어요.
{listed}
테스트의 단언이나 케이스를 바꿨다면, 커밋 전에 mutation을 심어 false green을
걸러야 합니다. `{SKILL_NAME}` 스킬을 읽고 수행한 뒤, 커밋 메시지 끝에 아래 trailer를
한 줄 추가하고 다시 커밋하세요.
{TRAILER_KEY} killed <N> / survived <M>(<분류>) by {SKILL_NAME}
단언·케이스를 안 건드린 순수 리팩터(이름·서식·이동)라면 검증할 새 보장이 없으니
skip으로 남기세요.
{TRAILER_KEY} skip(no assertion change) by {SKILL_NAME}
이 게이트는 검증을 증명하지 못합니다. 서명만 먼저 쓰고 검증을 건너뛸 수도 있지만,
그러면 남는 건 아무것도 지키지 않는 초록불뿐입니다.
의식적으로 건너뛰려면 명령 앞에 'ALLOW_NO_MUTATION=1 ' 를 붙이세요."""
# ---------------------------------------------------------------------------
def main() -> None:
try:
event = json.load(sys.stdin)
except Exception:
allow()
cmd = (event.get("tool_input") or {}).get("command") or ""
if not cmd:
allow()
verdict = classify_command(cmd)
if verdict in ("skip", "external"):
allow()
files = staged_test_files()
if not files:
allow()
if TRAILER_KEY in cmd:
allow()
deny(build_reason(files))
if __name__ == "__main__":
try:
main()
except Exception:
# 훅의 버그가 커밋을 막는 일은 없어야 한다.
sys.exit(0)
name mutation-verify
author bumgeun-song
license MIT
description 테스트가 실제로 버그를 막는지 mutation(원본 코드를 일부러 망가뜨리는 변형)을 심어 검증하고, 결과를 커밋 메시지 trailer로 남긴다. 테스트의 단언이나 케이스를 새로 쓰거나 고쳤을 때, 커밋 게이트가 Mutation-Verified 기록을 요구했을 때, "이 테스트 믿어도 되나" "테스트가 제대로 검증하는지 봐줘" "false green 아닌지 확인해줘" 같은 요청이 있을 때 반드시 사용할 것. 커버리지를 올려달라거나 커버리지로 테스트 품질을 확인해달라는 요청도 이 스킬로 처리한다 — 커버리지는 코드가 실행됐다는 것만 말할 뿐 버그 방어력을 재지 않기 때문. 테스트를 새로 작성한 직후라면 요청이 없어도 이 검증을 먼저 제안한다 .

Mutation 검증

테스트의 단언을 쓰거나 바꾼 뒤, 그 테스트가 실제로 버그를 막는지 변형을 심어 확인합니다. false green — 통과하지만 아무것도 지키지 않는 테스트 — 를 커밋 전에 걸러내는 단계입니다.

좋은 테스트에는 여러 성질이 있습니다. 버그 방어, 리팩터링 내성, 빠른 피드백, 유지보수성. 이 스킬이 재는 건 그중 버그 방어 하나입니다. 나머지는 각 프로젝트의 테스트 규약이 맡습니다. 그래서 모든 판단은 한 질문으로 수렴합니다.

이 테스트는 사용자나 운영에 닿는 중요한 버그를 실제로 막나요?

unit / integration / E2E 어느 레벨이든 같은 절차를 적용합니다. 레벨에 따라 달라지는 건 "관찰 표면이 무엇인가" 하나뿐입니다.

전체 흐름

flowchart TD
    S0{"0. 관문<br>단언이나 케이스를 건드렸나"} -- "아니오" --> S9
    S0 -- "예" --> S1["1. 설계<br>어떤 변형을 심을까"]
    S1 --> S2["2. 실행<br>예측을 적고 · 심고 · 되돌린다"]
    S2 --> S3["3. 판정<br>분류하고 대응한다"]
    S3 -- "도달 안 함 → 다시 심는다" --> S1
    S3 --> S4{"찌를 실패면이<br>더 남았나"}
    S4 -- "예" --> S1
    S4 -- "아니오" --> S9["9. 종결<br>원복 · 이행 기록"]
Loading

1~3은 변형 하나마다 도는 루프입니다. 여러 변형을 한꺼번에 심으면 무엇이 무엇을 죽였는지 귀속되지 않습니다. 9는 루프를 다 빠져나온 뒤 한 번만 합니다.


0. 관문 — 이 검증이 필요한가

필요합니다: 단언이나 테스트 케이스를 새로 추가했거나 변경했다. 새 보장이 생겼으니 그게 진짜인지 확인해야 합니다.

건너뜁니다: 이름 변경, 서식, 파일 이동처럼 단언을 안 건드린 순수 리팩터링. 검증할 새 보장이 없습니다.

건너뛰는 경우에도 9. 종결 단계로 가서 건너뛴 사실을 기록합니다. 기록 없이 지나가면 "검증했는데 통과"와 "안 했다"가 구분되지 않습니다.


1. 설계 — 어떤 변형을 심을까

변형 후보는 코드 줄이 아니라 버그에서 시작합니다.

"이 조건문을 뒤집으면 깨질까"가 아니라 "이 정책이 깨지면 어떤 버그가 나는가"

이 단계의 상세 — 좋은 변형의 다섯 조건, 레벨별 관찰 표면의 정의, 실패면을 나누는 축, 후보 표 — 는 부록 A. 변형 설계를 읽고 진행하세요. 요약만 옮기면 조건 3(관찰 표면)과 조건 4(실패면 분리)에서 거의 반드시 실수합니다.

이 단계에서 반드시 남겨야 하는 산출물이 하나 있습니다.

테스트의 약속을 한 줄로 적는다

테스트가 보장하려는 문장을 변형을 심기 전에 적습니다.

(unit)        입력 조건 → 출력 계약
  일반 요금제가 입력되면, 재고가 있어도 computeDeliveryOption은
  standardOnly를 반환해야 한다.

(integration) 사용자 시나리오 → 관찰 결과
  당일배송 재고가 있어도, 일반 요금제에서는 당일배송 선택이 막히고
  구독 전용 안내가 보여야 한다.

이 한 줄이 3단계 판정에서 **"이 버그가 이 테스트의 약속 안인가 밖인가"**를 가르는 유일한 기준입니다. 여기가 흐릿하면 뒤의 판정이 통째로 불가능해집니다.


2. 실행 — 예측을 적고, 심고, 되돌린다

핵심은 심는 절차가 아니라 결과를 보기 전에 예측을 적는 것입니다. 예측 없이 결과를 보면 무엇이 나오든 말이 되고, 판정은 사후 합리화가 됩니다. 등가 변형 판정은 원래 결정 불가능한 문제라, 판정을 잘하려 하는 대신 틀렸을 때 드러나게 만듭니다.

변형 하나마다:

  1. 막으려는 버그테스트의 약속을 적는다
  2. 변경을 작게, 한 번에 하나만 적용한다
  3. 예측을 두 축으로 적는다
    • 무엇이 바뀌는가 — 관찰 표면에서의 결과
    • 어느 테스트가 어떤 실패를 내는가 — 테스트 이름과 실패 모양
  4. 테스트를 실행하고, 실제 실패가 예측과 일치하는지 본다
    1. 판정 단계으로 분류한다
  5. 변형 diff를 즉시 되돌리고, 원래 테스트가 다시 Green인지 확인한다
  6. 작업 트리가 깨끗한지 확인한다 — 포맷·import 정렬 diff도 잔여물이다

예측은 대화 컨텍스트가 아니라 파일에 적으세요. 컨텍스트 안에만 있으면 결과를 본 뒤 조용히 수정될 수 있고, 그러면 이 장치 전체가 무의미해집니다.

실패의 범위도 신호다

  • 표면 하나를 찌른 변형이 예상한 테스트 하나가 아니라 전부를 죽였다 → 단언들이 서로 얽혀 있다는 뜻
  • 공통 함수를 찌른 변형이 예상보다 좁게 죽었다 → 어떤 경로가 그 정책을 실제로 타지 않는다는 뜻

변형은 실험이지 구현 변경이 아닙니다. 어떤 경우에도 커밋에 남으면 안 됩니다.


3. 판정 — 분류하고 대응한다

결정 트리, 상황별 해설, 실패 메시지 다듬기, todo 작성법은 부록 B. 판정에 있습니다. 변형이 살아남았다면 반드시 읽고 판정하세요.

여기서는 자주 틀리는 두 가지만 미리 못 박습니다.

"바꿔도 결과가 같은 변형"을 성급히 붙이지 마세요. 트리에서 판정이 가장 어렵고 가장 자주 틀리는 자리입니다. 진짜 동일 동작은 모든 입력에서 결과가 같아야 하는데, 보통은 "지금 이 픽스처에서는 같아 보인다"에 그칩니다. 후자는 등가가 아니라 픽스처가 그 차이를 안 태우고 있는 것입니다.

헷갈리면 "아무도 안 지킴"으로 적습니다. 놓친 버그로 남겨 두는 편이, 안 지키는 것을 지킨다고 착각하는 것보다 항상 안전합니다.

멈출 때

하나의 정책에 대해 서로 다른 실패면 3~5개를 검증했다면 다음 정책으로 넘어갑니다.

숫자가 목표가 아닙니다. "서로 다른" 실패면이 핵심입니다. 같은 단언을 다섯 번 때린 것은 하나로 셉니다.


9. 종결 — 원복과 이행 기록

변형은 그때만 하는 실험이라 코드에 흔적을 남기지 않습니다. 남기는 건 "이 절차대로 검증했다"는 한 줄뿐이고, 커밋 메시지 끝에 trailer로 적습니다.

Mutation-Verified: killed 3 / survived 1(아무도 안 지킴) by mutation-verify

0단계에서 건너뛴 경우도 남깁니다.

Mutation-Verified: skip(no assertion change) by mutation-verify
  • 커밋에 테스트 파일이 있으면 훅이 이 trailer를 확인하고, 없으면 커밋을 막습니다.
  • killed / survived 숫자는 이력용 기록이지 목표가 아닙니다. 놓침은 분류와 함께 적습니다.
  • trailer는 검증했다는 표시일 뿐 검증의 질을 보장하지 않습니다. 질은 이 문서의 나머지가 정합니다. 서명만 먼저 쓰고 검증을 건너뛰면 손해는 본인이 봅니다.

커밋 전 최종 점검

  • 변형 diff가 하나도 남아 있지 않은가 (git diff, git status 둘 다)
  • 원래 테스트가 다시 Green인가
  • 관련된 다른 레벨 테스트도 의도대로 통과하는가
  • 커밋 메시지에 Mutation-Verified: trailer를 남겼는가

이 스킬이 만들 수 있는 최악의 결과는 부실한 테스트가 아니라, 일부러 심은 버그가 배포되는 것입니다. 중간에 실패하거나 중단되더라도 원복이 항상 먼저입니다.


최종 원칙

좋은 테스트의 마지막 질문: 이 테스트는 AI가 만든 구현을 신뢰하는 데 실제 근거가 되나요?

좋은 산출물은 잡은 개수가 아니라 다음 넷입니다.

  • 이 테스트가 어떤 버그를 막는지
  • 어떤 실패면을 실제로 잡았는지
  • 어떤 변형이 놓쳤고 왜 놓쳤는지
  • 그래서 이 테스트를 어디까지 믿어도 되는지

부록 A. 변형 설계

본문 1. 설계 단계에서 읽습니다.

좋은 변형의 다섯 조건

좋은 변형은 테스트의 버그 방어력을 실제로 시험하는 변형입니다. 다섯 조건을 모두 만족해야 합니다. 하나라도 어기면 실행해도 정보가 나오지 않습니다.

조건 어기면 생기는 일
1. 실제 버그를 일으킨다 프로덕션에 나가도 아무도 안 다치는 변형. 죽든 살든 알 게 없음
2. 이번 픽스처에서 도달한다 심은 자리가 실행조차 안 됨. 살아남아도 테스트 탓이 아님
3. 관찰 표면에서 결과가 바뀐다 코드는 망가졌는데 그 레벨에서 안 보임. 테스트가 볼 방법이 없음
4. 앞 후보와 실패면이 다르다 같은 단언을 반복해서 때림. 두 번째부터는 새 정보 없음
5. 컴파일된다 실험이 성립하지 않음. 타입 에러는 버그가 아니라 컴파일러의 일

조건 1은 현실 축입니다 — 프로덕션에서 사용자가 다치는가. 조건 3은 테스트 축입니다 — 이 레벨의 관찰 표면에 드러나는가.

둘이 갈리는 자리가 mock에 가려지는 경우입니다. 버그는 실재하는데 가짜 데이터가 버그 경로를 끊어서 테스트 세계에는 나타나지 않습니다. 이 구분은 판정에서 다시 쓰이니 기억해두세요.

관찰 표면이란

관찰 표면은 그 테스트 레벨이 볼 수 있는 결과의 범위입니다.

unit — 반환값(출력 계약)

  • 반환값이 standardOnly에서 sameDay로 바뀜
  • 경계 입력의 출력이 다른 union 멤버로 넘어감
  • ❌ 내부 변수·중간 계산만 바뀌고 반환값은 그대로 → 조건 3 위반

integration — 사용자에게 보이는 화면·플로우·부수 효과

  • 일반 요금제에서 당일배송 옵션이 선택 가능해짐
  • 수수료 안내 화면 없이 다음 단계로 넘어감
  • feature flag가 off인데 진입 퍼널이 렌더됨
  • ❌ 내부 함수 호출 순서만 바뀌거나 사용자가 못 보는 리팩터링 차이 → 조건 3 위반

콜백 예외 (integration). 화면에 안 보여도 "다음 단계로 넘어간다"는 신호인 콜백은 관찰 표면에 넣습니다. 예를 들어 주문 확정 시 호출되는 onConfirmOrder가 불렸다면, 사용자에게는 다음 화면이 열린 것과 같습니다.

AI가 만드는 변형의 함정

AI가 심는 변형은 실제 버그에 가까워서 약한 테스트를 잘 드러냅니다. 하지만 조건 2·3·4·5를 자주 어깁니다. 실제로 LLM이 생성한 변형은 규칙 기반 도구보다 컴파일 실패율이 훨씬 높다는 보고가 있습니다. 조건 5를 형식적인 항목으로 취급하지 마세요.

특히 죽은 분기가 위험합니다. 많은 JS 테스트 러너는 실행 시 타입 검사를 하지 않기 때문에, 도달하지 않은 변형도 멀쩡히 실행된 뒤 "놓침"으로 기록됩니다. 그러면 테스트가 약하다는 뜻으로 오독하기 쉽습니다.

조건을 만족시키는 순서

1) 막으려는 버그를 고른다 (조건 1)

변형 후보는 코드 줄이 아니라 버그에서 시작합니다.

일반 요금제 사용자가 당일배송 선택 단계로 진입한다.
feature flag가 off인데도 파일럿 퍼널에 진입한다.
추가 배송비가 있는데 확인 화면 없이 주문이 진행된다.
재고가 있는 옵션이 선택 화면에서 사라진다.
할인 임계 금액이 낮아져 회사가 비용을 떠안는다.

변형은 이 버그를 실제로 일으키는 방향이어야 합니다.

2) 테스트의 약속을 적는다

SKILL의 1단계에 형식이 있습니다. 이 문장이 판정 트리에서 **"약속 안인가 밖인가"**를 가르는 기준이 됩니다. 여기서 흐릿하면 판정이 불가능해집니다.

3) 서로 다른 실패면으로 나눈다 (조건 4)

같은 단언을 반복해서 때리지 않습니다. 한 정책에 연결된 여러 지점을 나눠서 찌릅니다. 실패면의 모양은 레벨마다 다릅니다.

unit — 한 함수 안의 서로 다른 정책 조항

축: 게이트별 조건 / 경계값 / alias 정규화 그룹 / 게이트 간 격리

정책: "일반 요금제는 당일배송이 막혀야 한다"

  1. 허용 목록에 일반 요금제를 추가 (가드 무력화)
  2. 재고 경계 > 0>= 0으로 이동
  3. alias 정규화 그룹에서 멤버 하나 누락
  4. 다른 게이트의 입력을 참조 (게이트 누설)

integration — 정책이 지나는 layer 사슬

API 응답 해석 → 재고·요금제 상태 계산 → 정책 계산
→ 선택된 옵션 상태 연결 → UI 분기 → 실제 확정 동작 차단

정책: "일반 요금제는 당일배송이 막혀야 한다"

  1. 당일배송 재고를 available로 잘못 해석
  2. 구독 전용 정책 조건 제거
  3. 선택된 요금제 대신 다른 상태를 참조
  4. 안내는 띄우지만 return을 빼서 확정 콜백도 함께 호출
  5. standardOnly 분기를 soldOut처럼 처리

특별한 이유가 없으면 첫 후보는 가드 무력화로 잡습니다. 정책의 차단 조건이 아예 실행되지 않게 만드는 변형입니다. 실제 버그에서 가장 흔한 모양이 "가드가 조용히 죽는" 것이기 때문입니다.

공통 함수(helper)를 둔 정책은 helper와 호출 지점(callsite)이 서로 다른 질문에 답합니다.

  • helper 변형 → "정책 자체가 맞는가"
  • callsite 변형 → "각 사용자 경로가 그 정책을 실제로 호출하는가"

unit에서는 helper만 찌릅니다. callsite 질문은 integration의 책임이라, callsite 변형이 살아남으면 "다른 테스트가 지킴"으로 분류합니다. integration에서는 둘을 나눠서 모두 찌릅니다.

4) 후보 표에서 고른다

✅ 좋은 후보 (실제 버그로 연결) ❌ 효과 낮은 후보
정책 조건 제거·반전 단순 문구 변경
API 응답 필드 오해 타입·빌드 에러만 내는 변경
선택한 상태 대신 다른 상태 참조 현실적이지 않은 무작위 코드 삭제
안내 UI는 뜨지만 진행도 함께 발생 사용자가 못 보는 내부 리팩터링 차이
feature flag off인데 진입이 열림 현재 테스트 책임 밖 정책의 억지 검증
추가 비용이 있는데 확인 단계 건너뜀 이미 같은 실패면을 검증한 변형의 반복
재고가 있는데 옵션 필터에서 누락
실패 응답을 성공처럼 처리
경계값 이동 (>>=)
alias 정규화 그룹에서 멤버 누락

문구 변경은 특히 조심합니다. 법적·고지성 문구가 아니면 대부분 유지보수 비용만 늘립니다.

5) 심기 전에 검산한다 (조건 2·5)

도달은 프로그램으로 확인할 수 있습니다. 분기(branch) 커버리지를 보세요. 라인 커버리지로는 부족합니다. 조건문 줄은 매번 실행돼도, 변형이 여는 반대편 팔은 한 번도 안 열렸을 수 있습니다.

# 예 (러너에 맞게 조정)
vitest run <테스트파일> --coverage.enabled
pytest <테스트파일> --cov --cov-branch

한 번 돌리면 후보 전체를 스크리닝할 수 있습니다. 나머지는 손으로 자문합니다.

  • 이 분기가 이번 시나리오에서 열리나 (조건 2 — 위 명령으로 확인)
  • 컴파일은 되나 (조건 5)
  • 앞 후보와 실패면이 겹치나 (조건 4)
  • 관찰 표면에서 결과가 진짜 바뀌나 (조건 3)

조건 3은 도구가 못 걸러냅니다. "실행은 되는데 결과가 안 보이는" 경우라서, 판정 단계에 위임합니다.


부록 B. 판정

본문 3. 판정 단계에서 읽습니다.

결정 트리

변형 실행 결과
│
├─ 잡힘(killed)
│     대응 → 실패 메시지가 정책 언어로 읽히는지 확인.
│             안 읽히면 단언을 다듬는다 (아래 「실패 메시지 다듬기」)
│
└─ 놓침(survived)
   │
   ├─ 실행조차 안 됨 = 도달 안 함
   │     대응 → 무정보. 버리고 다른 자리에 다시 심는다.
   │             놓침으로 세지 않는다.
   │
   └─ 실행됨
      │
      ├─ 모든 입력에서 결과가 같음 = 바꿔도 결과가 같은 변형
      │     대응 → 없음.
      │             "이번 픽스처에서만 같음"과 혼동 주의 (아래 해설)
      │
      └─ 입력에 따라 결과가 달라짐 = 버그 실재
         │
         ├─ 이 테스트의 약속 밖
         │  ├─ 다른 테스트가 지킴
         │  │     대응 → 없음
         │  └─ 아무도 안 지킴
         │        대응 → todo로 미래 테스트의 약속 한 줄
         │
         └─ 이 테스트의 약속 안
            ├─ 단언을 안 걸었음
            │     대응 → 지금 단언을 추가·강화한다
            └─ mock 데이터에 가려짐
                  대응 → 지금 mock을 현실에 가깝게 고친다

대응은 아래로 갈수록 무거워집니다. 위쪽은 할 일이 없거나 다음 회차로 넘기지만, 맨 아래 둘은 커밋 전에 지금 고쳐야 합니다.

"약속"은 1단계에서 적어둔 그 한 줄입니다. 그게 없으면 이 트리를 탈 수 없습니다.

상황별 해설

잡힘(killed)

테스트가 심어둔 버그를 잡았습니다. 끝이 아닙니다. 실패 메시지가 원인을 못 알려주면, 진짜 버그가 났을 때 동료가 CI 로그만 보고 원인을 찾지 못합니다.

도달 안 함

심은 자리가 이번 픽스처에서 실행되지 않았습니다. 테스트가 못 잡은 게 아니라 잡을 기회가 없었던 것이라, 놓침으로 세면 안 됩니다. 분류가 아니라 무효 실험입니다. 다른 자리에 다시 심습니다.

바꿔도 결과가 같은 변형

어떤 입력을 넣어도 결과가 같은 변형입니다.

성급히 붙이지 마세요. 이 트리에서 판정이 가장 어렵고 가장 자주 틀리는 자리입니다. 진짜 동일 동작은 모든 입력에서 결과가 같아야 하는데, 실제로는 "지금 이 픽스처에서는 결과가 같아 보인다"에 그치는 경우가 대부분입니다. 후자는 등가가 아니라 픽스처가 그 차이를 안 태우고 있는 것이라, "아무도 안 지킴"이거나 케이스 부족입니다.

둘 사이에서 헷갈리면 "아무도 안 지킴"으로 적습니다. 놓친 버그로 남겨 두는 편이, 안 지키는 것을 지킨다고 착각하는 것보다 안전합니다.

다른 테스트가 지킴

버그는 실재하지만 이 테스트의 약속 밖이고, 이미 다른 테스트가 책임집니다. 예를 들어 unit에서 callsite 변형이 살아남으면 integration이 그 질문을 맡습니다. 할 일이 없습니다.

아무도 안 지킴

버그는 실재하고 중요한데 지금 어떤 테스트도 보고 있지 않습니다. 이 테스트가 보장하지 않는 범위를 드러낸 것이라, 놓침 중 가장 값진 결과입니다. 변형을 심지 않았으면 영영 몰랐을 구멍입니다. 부채로 등록합니다.

단언을 안 걸었음

버그가 실재하고, 약속 안이고, 관찰 표면에도 드러나는데, 그걸 보는 단언이 없어서 통과했습니다. 이 테스트의 결함입니다. 분류만 하고 넘어가지 말고 지금 단언을 추가합니다.

mock 데이터에 가려짐

버그가 실재하고 약속 안인데, 가짜 데이터가 버그 경로를 끊어 테스트 세계에 나타나지 않습니다. 이 트리에서 유일하게 테스트 구조 자체가 잘못됐다는 신호입니다.

보통 과잉 mock의 증상입니다. 직접 책임지는 코드까지 가짜로 바꾸지 않았는지 확인하세요. 어디까지 mock해도 되는지는 프로젝트의 테스트 규약을 따릅니다.

실패 메시지 다듬기

잡았는지만 보면 부족합니다. 실패 메시지만 보고 어떤 버그가 났는지 이해할 수 있어야 합니다.

✅ 좋은 실패 (unit — 행 이름 + 리터럴 diff가 정책 언어)
FAIL ... > 일반 요금제는 재고가 있어도 standardOnly다
expected 'sameDay' to be 'standardOnly'

✅ 좋은 실패 (integration — 메시지가 정책 언어)
Unable to find role="heading" name="당일배송은 구독 회원만 이용할 수 있어요"
expected onConfirmOrder not to have been called, but it was called with a standard-plan payload

❌ 나쁜 실패 메시지 (레벨 공통 — 원인 불명)
Unable to find element
expected false to be true
Cannot read properties of undefined

나쁜 메시지는 기록하고 넘어갈 관찰이 아니라 테스트를 고치라는 신호입니다.

실패 메시지는 변형이 아니라 테스트 단언이 결정합니다. 지금 이 변형이 만든 메시지는, 나중에 진짜 버그가 났을 때 동료가 CI에서 보게 될 메시지와 같습니다. 커밋 전에 단언을 다듬으세요.

unit — 정책 언어는 단언 메시지가 아니라 테스트 이름에 삽니다.

  • 여러 케이스가 한 it에 몰려 어떤 입력이 뚫렸는지 안 보이면, 케이스를 테이블 기반 (it.each 등) 행으로 펼쳐서 실패한 행 이름이 곧 정책 메시지가 되게 합니다.

integration

  • 앵커 단언이 정책 단언을 가려서 실패한다면, 앵커를 정책과 무관하게 항상 렌더되는 요소로 교체합니다. (앵커를 부정 단언보다 앞에 두는 순서 자체는 건전성 조건이므로 유지합니다.)
  • 원시값 비교가 expected false to be true를 낸다면, 단언에 정책 메시지를 실어줍니다. expect(값, '배너 클릭은 영구 저장돼야 한다')

「단언을 안 걸었음」의 대응도 여기로 옵니다. 하나는 단언이 아예 없는 것이고 다른 하나는 단언이 뭉툭한 것이라, 둘 다 단언의 결함입니다.

안 지켜지는 버그를 todo로

"아무도 안 지킴"으로 분류한 놓침은 그 자리에서 todo로 남깁니다. 러너가 제공하는 빈 테스트 문법(it.todo, test.todo, @skip 등)을 씁니다.

todo 메시지에는 발견 경위가 아니라 미래 테스트의 약속을 적습니다. 맥락 없이 이 파일을 여는 다음 사람이 본문만 채우면 되도록 씁니다.

// ❌ 발견 경위 — 무엇을 단언할지 알 수 없다
it.todo('상태 계산 변형이 살아남음');

// ✅ 약속 형식 — 이대로 본문만 채우면 된다
it.todo('당일배송 재고가 없는 옵션은 선택 화면에 노출되지 않는다');

약속 한 줄이 안 써지면 "아무도 안 지킴"이 아니라 "다른 테스트가 지킴"입니다. todo 작성이 분류의 검산을 겸합니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment