特徵測試通過不代表測試有效,刻意修改程式碼才能驗證
A Passing Characterization Test Proves Nothing Until You Break the Code
作者提出一套重構前的特徵測試驗證方法:每個新增測試都必須至少讓一項刻意植入的程式碼變異導致測試失敗。流程包括固定時間、ID 和環境輸入、記錄現有輸出、手動編寫變異,並逐一執行對應測試,再比較修改程式碼前後的變異結果。這只能確認測試有觀察到相關行為,不能證明行為正確;文章不建議用於整體重寫或單次測試耗時數分鐘的情況。
Most characterization tests in a messy repo are decoration. They pass before the refactor. They pass after it. They would also pass if the branch under test were deleted.
That is not a test. It is a receipt. This post adds one cheap gate: prove each new test can fail, by breaking the code on purpose.
The failure mode: tests that assert nothing
A vacuous test usually appears during refactor prep. Someone snapshots a return value. Or writes assert result is not None. It goes green, the team feels safe, and the gate stays open.
Then the refactor changes rounding, ordering, or a default. The suite stays green. The bug ships. The test was never guarding the seam you touched.
Coverage does not catch this. A line can execute and still be unobserved. The only honest question is: which code change makes this test fail?
The rule: one test, one kill
Adopt a single rule for refactor prep. Every new characterization test must kill at least one deliberate mutation.
A mutation is a tiny, mechanical edit to the source. Change >= to > — swap a default — drop a rounding call. If the test still passes, the test cannot see that behavior.
Keep the mutant set small and hand-written. You are guarding a seam, not scoring the whole file.
Step 1: Pick the seam, not the file
Refactors fail at boundaries, not at file scope. Name the boundary before you write anything: a function, a public method, a serialized payload.
Write that name down. Also write the caller that must keep working. If you cannot name the caller, you are not ready to refactor.
Step 2: Freeze every input you cannot control
Mutation results are worthless when the test is random. Pin time, IDs, and env reads behind module-level functions you can patch.
# tests/conftest.py
import pytest
from billing import prorate
@pytest.fixture(autouse=True)
def frozen_inputs(monkeypatch):
monkeypatch.setattr(prorate, "_now", lambda: "2026-03-01T00:00:00Z")
monkeypatch.setattr(prorate, "_new_id", lambda: "id-fixed-001")
monkeypatch.setenv("BILLING_ROUNDING", "half_up")
Run the target test five times in a row. If any run differs, stop. Fix determinism before you fix anything else.
Step 3: Capture real bytes, then pin them
Run the current code and record what it actually produces. Not what you believe it should produce. Paste that output into the assertion.
def test_partial_month():
out = prorate.compute(days_left=1, cents=1234)
assert out == {"amount_cents": 1234, "label": "1 day"}
Ugly golden values are fine here. They are temporary scaffolding for the diff. You delete or re-derive them after the behavior is documented.
Step 4: Write mutants by hand
List the edits that would break a caller without crashing. Boundaries, defaults, rounding, and ordering are the usual suspects.
Keep them as data, not as a tool config. Two to six entries per seam is enough.
MUTANTS = [
{"id": "M1", "file": "billing/prorate.py",
"old": "if days_left >= 1:", "new": "if days_left > 1:",
"target": "tests/test_prorate.py::test_partial_month"},
{"id": "M2", "file": "billing/prorate.py",
"old": "return round(cents, 2)", "new": "return round(cents, 0)",
"target": "tests/test_prorate.py::test_partial_month"},
]
Each mutant must match exactly once. A pattern that matches zero or three times is a broken mutant, not a survived one.
Step 5: Run the loop
The runner copies the repo, applies one mutant, runs one test node, then restores the file. Classify the exit code honestly.
# mutate.py — unexecuted scaffolding; adapt paths and selectors first.
import shutil, subprocess, sys, tempfile
from pathlib import Path
from mutants import MUTANTS
REPO = Path(".").resolve()
def classify(code: int) -> str:
if code == 1: return "killed" # assertion failed: the test saw it
if code == 0: return "survived" # test ignored the change
if code == 5: return "no-tests" # selector matched nothing
return "error" # 2 interrupted, 3 internal, 4 usage
def run(mutant):
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) / "repo"
shutil.copytree(REPO, work, ignore=shutil.ignore_patterns(".git", "__pycache__"))
src = work / mutant["file"]
original = src.read_text()
if original.count(mutant["old"]) != 1:
return mutant["id"], "bad-pattern"
src.write_text(original.replace(mutant["old"], mutant["new"]))
proc = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "--no-header", mutant["target"]],
cwd=work, capture_output=True, text=True, timeout=300,
)
return mutant["id"], classify(proc.returncode)
failed = False
for m in MUTANTS:
mid, verdict = run(m)
print(f"{mid:<4} {verdict:<12} {m['target']}")
failed |= verdict in {"survived", "no-tests", "bad-pattern", "error"}
sys.exit(1 if failed else 0)
Exit code 5 deserves attention. It means the selector matched no tests, which silently poisons the whole gate.
Step 6: Read the results like a table
| Verdict | What it means | Action |
|---|---|---|
| killed | the test observed the mutation | keep it, proceed |
| survived | the test ignored the seam | tighten the assertion or delete the test |
| no-tests | wrong selector | fix the selector, re-run |
| bad-pattern | mutant does not match once | rewrite the mutant |
| error | harness or collection broke | stop and fix the loop |
Survivors split into two kinds. Real gaps get a tighter assertion. Equivalent mutants — edits with no observable behavior change — should be deleted from the list, not chased.
Where a free model helps, and where it does not
Writing mutants by hand is fast. Finding the seams you forgot is not. A model is decent at proposing candidate assertions and candidate mutants for unfamiliar code.
Give it the function and the callers. Ask for boundary inputs and, for each input, the one-line edit that should change the output. Then run the loop.
The loop is the judge. A model proposal that kills nothing is dropped, no matter how plausible the reasoning sounds. Free model access on MonkeyCode is enough for this propose-then-verify pattern, since the artifact is text, not a passing test suite. The same loop runs fine on a remote machine; MonkeyCode's free server option is one place to park it if you would rather not pin a laptop core. Check the current quotas and limits yourself — free tiers change without notice.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Step 7: Make the smallest safe change
Now edit production code. One seam per commit. Then re-run the exact same mutant set.
The kill set must be identical before and after. New kills mean you changed behavior nobody asked for. Lost kills mean the test stopped guarding the seam.
That comparison is the gate. It is also cheaper than reviewing a large diff by eye.
Limitations and who should skip this
- Mutation runs are slow. One test node per mutant keeps it tolerable; whole-suite runs do not.
- Textual mutants can produce syntax errors. Treat those as harness bugs, not kills.
- Equivalent mutants waste time forever. Delete them aggressively and note why.
- This proves tests observe behavior. It does not prove the behavior is correct.
- Skip it during rewrites. If the seam is being replaced, characterization is the wrong tool.
- Skip it if a single test run already takes minutes. Fix runtime first.
Start with one module and one seam. Run the loop once. Count the survivors before you write any new test.
That count is your real starting point for the refactor.
來源:DEV Community · dev.to