A Passing Characterization Test Proves Nothing Until You Break the Code

2026-11-06来源 dev.to PythonEnglish

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.

原文:https://www.a8q9.cn/en/article/a-passing-characterization-test-proves-nothing-until-you-break-the-code-fc9a01.html

相关文章