A characterization test only protects the lines it executes.
If your test never reaches the branch you plan to move, it cannot tell you the move broke it. That sounds obvious. It is also the most common reason messy-repo refactors roll back.
This post describes one gate. Measure reach first. Then make the smallest safe change.
The rule
Before editing, declare the exact line range you intend to change.
Then prove that a test executes every line in that range.
If the gate fails, you have two choices. Add a case, or shrink the range.
There is no third option called refactor anyway.
Step 1: Turn the seam into line numbers
Intuition drifts between reviewers. Line numbers do not.
Get the range with a twelve-line helper.
# seam.py - print the line range of one function import ast import sys path, name = sys.argv[1], sys.argv[2] tree = ast.parse(open(path).read()) for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name: print('%d-%d' % (node.lineno, node.end_lineno)) break else: sys.exit('seam.py: %s not found in %s' % (name, path)) Run it against the function you want to move.
$ python seam.py billing/render.py render_invoice 214-268 Record that range in the PR description. It becomes the definition of small change for this refactor.
Step 2: Draft the characterization tests
The goal here is not elegance. The goal is observability.
Feed the tests real payloads from logs, fixtures, or a captured run. Assert on things you can compare later: strings, dicts, status codes, exception types.
Drafting ten of these tests by hand is slow and boring. This is the one place where free model access genuinely helps. Draft the boilerplate shape on MonkeyCode, then delete what you cannot defend in review.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Treat drafted tests as inputs, not evidence. A test you did not read is a liability with a green checkmark.
Step 3: Measure reach with coverage
Coverage is the cheapest available proxy for reach.
coverage run -m pytest tests/test_render_char.py -q coverage json -o coverage.json Now apply the gate. The script reads the coverage JSON, expands your planned range, and exits non-zero on any line the tests never touched.
# reach_gate.py - fail if planned lines are never executed import argparse import json import sys def parse_ranges(spec): out = [] for part in spec.split(','): part = part.strip() if not part: continue if '-' in part: lo, hi = part.split('-', 1) out.append((int(lo), int(hi))) else: out.append((int(part), int(part))) return out def expand(spec): lines = set() for lo, hi in parse_ranges(spec): lines.update(range(lo, hi + 1)) return lines def main(): ap = argparse.ArgumentParser() ap.add_argument('--coverage', default='coverage.json') ap.add_argument('--file', required=True) ap.add_argument('--lines', required=True) ap.add_argument('--allow-missing', default='') args = ap.parse_args() with open(args.coverage) as fh: report = json.load(fh) entry = report['files'].get(args.file) if entry is None: print('reach-gate: no coverage entry for %s' % args.file) return 2 executed = set(entry['executed_lines']) planned = expand(args.lines) allowed = expand(args.allow_missing) uncovered = sorted(planned - executed - allowed) print('reach-gate: planned=%d reached=%d allowed=%d uncovered=%d' % ( len(planned), len(planned & executed), len(planned & allowed), len(uncovered))) if uncovered: print('uncovered: ' + ','.join(str(n) for n in uncovered)) return 1 return 0 if __name__ == '__main__': sys.exit(main()) A failing run looks like this. The exit code matters more than the message.
$ python reach_gate.py --file billing/render.py --lines 214-268 reach-gate: planned=55 reached=41 allowed=0 uncovered=14 uncovered: 226,227,231,236,240,244,247,251,255,260,262,264,266,268 $ echo $? 1 Step 4: Decide what to do with the gaps
Fourteen uncovered lines in a fifty-five line range is normal. Do not panic. Classify them.
| Situation | Action |
|---|---|
| Defensive branch you cannot trigger | Allow-list it with a comment and a follow-up issue |
| Needs infra you do not have (SMTP, S3, clock) | Fake the boundary, keep the branch |
| Genuinely unreachable | Delete it in a separate commit before the refactor |
| Belongs to another function | Shrink the range, you drifted |
Prefer shrinking over allow-listing. A smaller covered range beats a larger excused one.
Step 5: Freeze outputs before you edit
Coverage proves reach. It does not prove sameness. You need a second artifact.
This harness runs every case and writes a normalized snapshot.
# tests/char_harness.py import json import sys from billing.render import render_invoice from tests.char_cases import CASES def normalize(value): if isinstance(value, dict): return {k: normalize(value[k]) for k in sorted(value)} if isinstance(value, (list, tuple)): return [normalize(v) for v in value] if isinstance(value, set): return sorted(normalize(v) for v in value) return value def snapshot(): out = {} for name, payload in CASES: try: out[name] = {'ok': True, 'value': normalize(render_invoice(payload))} except Exception as exc: out[name] = {'ok': False, 'error': type(exc).__name__} return out if __name__ == '__main__': with open(sys.argv[1], 'w') as fh: json.dump(snapshot(), fh, indent=2, sort_keys=True) Capture the baseline before touching the file.
python -m tests.char_harness goldens/render_before.json git add goldens/render_before.json Note the deliberate omission: exception messages are dropped, types are kept. Messages drift across versions. Types are the contract.
Step 6: Make the smallest change, then rerun both gates
Smallest means the diff touches only lines inside the gated range. Anything outside that range stops the work.
Line numbers shift after the edit, so recompute the range first.
python seam.py billing/render.py render_invoice python reach_gate.py --file billing/render.py --lines 205-259 python -m tests.char_harness goldens/render_after.json diff -u goldens/render_before.json goldens/render_after.json An empty diff means behavior held on the cases you chose. A reach failure after the move means you changed control flow more than you planned. Treat that as a signal, not a formality.
If you would rather not install this toolchain locally, the free model access and free server option on MonkeyCode will run the same loop: draft tests, execute the gate, keep the artifacts in the repo. The gate logic is yours either way.
Where this gate is weak
Coverage measures reach, not assertion strength. A test can execute a line and assert nothing about it. Pair this with a review pass or an injected-fault run.
Line ranges are brittle. Any upstream commit invalidates them. Recompute on every branch, not once per quarter.
Golden files rot. They encode today's bugs on purpose. Label them as characterization, never as specification.
Time, randomness, and concurrency destabilize outputs. Normalize those fields explicitly or exclude the cases and say so.
The tooling here is Python-specific. The idea ports to other languages; the commands do not.
Who should skip this
Code with no stable observable output. If nothing comes out of the function, there is nothing to freeze. Add boundary logging first.
Refactors that are really rewrites. A new module with new behavior is a parity problem you cannot solve, and you should not pretend otherwise.
Suites too slow to run twice per change. A gate that takes twenty minutes gets bypassed by Thursday.
The short version
Write down the lines. Prove the tests reach them. Freeze the outputs. Change one thing. Rerun both gates.
That order is the entire method. Everything else is commentary.
