Coverage on exit paths is the extract gate. A helper move is unsafe until those paths stay pinned. Characterization tests must fail closed before the edit.
The failure mode
Messy functions hide early returns beside file writes. An extract often drops one of those returns. Later unit tests then certify the loss as intent.
This is a process failure, not a naming failure. The missing signal is an inventory of exits. Without that list, reviewers debate style instead of behavior.
Scope of this workflow
This article targets one function with mixed I/O. It does not cover a package-wide redesign effort. It does not use snapshot files or CLI tapes.
The artifact is a path table, five tests, and a coverage gate. The change after the gate is one pure helper. Anything larger belongs in a different change.
The messy module
Treat the code below as a labeled teaching example. It is not a production metric source. Do not treat totals as real billing data.
# report.py
import json
from pathlib import Path
def write_summary(rows, out_path, min_total=0):
if rows is None:
raise ValueError("rows required")
if not rows:
Path(out_path).write_text("{}\n")
return "empty"
total = 0
skipped = 0
for row in rows:
if not isinstance(row, dict):
skipped += 1
continue
amount = row.get("amount")
if amount is None:
skipped += 1
continue
try:
total += int(amount)
except (TypeError, ValueError):
skipped += 1
if total < min_total:
return "below-min"
payload = {"total": total, "skipped": skipped}
Path(out_path).write_text(json.dumps(payload) + "\n")
return "ok"
Count the exits before you touch a name. This function still has five observable exits. File presence is part of the observation, not noise.
1. Inventory every exit path
Walk the source once with no edits. Write a table with path, trigger, and observation. Store that table in the repo beside the tests.
| id | trigger | observation |
|---|---|---|
| P1 | rows is None |
ValueError; output file absent |
| P2 |
rows is empty list |
return "empty"; file equals {}\n
|
| P3 | total below min_total
|
return "below-min"; file absent |
| P4 | all rows skipped, min_total 0 |
return "ok"; skipped count in file |
| P5 | mixed valid and invalid rows | return "ok"; totals match inputs |
A missing table row blocks the pull request. Reviewers then check path ids rather than taste. New branches require a new id first.
2. Pin one characterization test per id
Use pytest together with a temp directory. Assert return values and the filesystem facts only. Do not assert helper names that do not exist yet.
# test_write_summary_paths.py
import json
import pytest
from report import write_summary
def test_p1_none_raises_without_file(tmp_path):
out = tmp_path / "s.json"
with pytest.raises(ValueError, match="rows required"):
write_summary(None, out)
assert not out.exists()
def test_p2_empty_list_writes_empty_object(tmp_path):
out = tmp_path / "s.json"
status = write_summary([], out)
assert status == "empty"
assert out.read_text() == "{}\n"
def test_p3_below_min_skips_write(tmp_path):
out = tmp_path / "s.json"
status = write_summary([{"amount": 3}], out, min_total=10)
assert status == "below-min"
assert not out.exists()
def test_p4_all_invalid_rows_still_ok(tmp_path):
out = tmp_path / "s.json"
rows = ["x", {"amount": None}, {"amount": "nope"}]
status = write_summary(rows, out)
assert status == "ok"
assert json.loads(out.read_text()) == {"total": 0, "skipped": 3}
def test_p5_mixed_rows_total_and_skip(tmp_path):
out = tmp_path / "s.json"
rows = [{"amount": 4}, "bad", {"amount": "6"}]
status = write_summary(rows, out)
assert status == "ok"
assert json.loads(out.read_text()) == {"total": 10, "skipped": 1}
Those five ids map to five tests. That mapping is the contract for the extract. Extra assertions on private names are out of bounds.
3. Fail the job on missing branches
Install pytest and coverage in a clean environment. Measure branch coverage on the report.py file only. Fail the job under 100 percent for that file.
python -m pip install pytest coverage
coverage run --branch --include=report.py -m pytest test_write_summary_paths.py -q
coverage report --include=report.py --show-missing --fail-under=100
Read the Missing column before any extract. A listed branch is an unpinned path. Add a table row and a test, then stop.
Do not raise the whole-repo coverage target here. Whole-repo noise will hide the local gate. Keep the include filter tight on purpose.
How to read a coverage miss
A miss like 12->15 is an unpinned branch. Do not extract while that uncovered arrow remains. Add the trigger that takes 12 to 15.
The report below is a labeled example. It is not measured from production traffic.
Name Stmts Miss Branch BrPart Cover Missing
report.py 28 0 12 1 97% 18->21
Coverage below 100 percent fails the job. The 18->21 row is the next test. Stop the extract until that row disappears.
4. Extract one pure helper only
Move the accumulation loop only in this step. Leave the path writes inside write_summary for now. That split keeps the original seam stable.
def accumulate(rows):
total = 0
skipped = 0
for row in rows:
if not isinstance(row, dict):
skipped += 1
continue
amount = row.get("amount")
if amount is None:
skipped += 1
continue
try:
total += int(amount)
except (TypeError, ValueError):
skipped += 1
return total, skipped
Replace the loop with one accumulate call. Leave every return path inside write_summary unchanged.
def write_summary(rows, out_path, min_total=0):
if rows is None:
raise ValueError("rows required")
if not rows:
Path(out_path).write_text("{}\n")
return "empty"
total, skipped = accumulate(rows)
if total < min_total:
return "below-min"
payload = {"total": total, "skipped": skipped}
Path(out_path).write_text(json.dumps(payload) + "\n")
return "ok"
Re-run the same two coverage commands next. Any failure means you revert the helper. Do not patch tests to match the new shape.
5. Check the suite on a second machine
Local dirty trees can hide import side effects. A clean runner is a portability check. Repeat the same coverage commands on that runner.
MonkeyCode offers free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Copy report.py, the tests, and the path table. Run the coverage gate on that server. Use free model access only after the gate is green.
Ask the model for the smallest extract diff. Compare that diff to the path table. Reject diffs that change observations or move I/O.
Decision table for the extract
Use the table below during the review pass. Each signal maps to one required action.
| signal | required action |
|---|---|
| missing path id | block the extract |
| coverage miss on report.py | add a test, not a helper |
| test fail after extract | revert the helper |
| I/O moved with logic | split into two commits |
| model diff spans two behaviors | shrink the request |
Paste this table into the pull request body. Review then checks those signals rather than preference. The table is the process artifact for this change.
Limitations
Branch coverage is not the same as semantic equivalence. Surviving mutants can still exist after this gate. Encoding, clocks, and locale remain unpinned in this method.
Concurrent writers stay outside this method on purpose. Network I/O stays outside the method as well.
This workflow only pins the current observed behavior. It does not prove the behavior is correct.
A 100 percent gate on a huge file is expensive. Shrink the target function before applying it. Do not start on a 2000-line module first.
Who should not use this
Skip this on greenfield code with a written spec. Write specification tests for that code instead. Pinning is for unknown behavior in brownfield code.
Skip this when the current behavior is known-harmful. Do not freeze a bug as a gold path. Fix that bug as an explicit expected-value change.
Skip this workflow without pytest and coverage. The coverage gate depends on both tools.
Skip this when the change must touch many files. Split that work into smaller changes first.
Claims this article does not make
No runtime benchmarks appear in this writeup. No model names, quotas, or hardware claims appear. Duration and permanence are not stated anywhere here.
The workflow stands if the product mention is removed. Reader value does not depend on that mention.
Closing rule
Inventory the exits before any helper extract. Pin each exit with one characterization test. Extract once and revert on a red gate.
Top comments (0)