DEV Community

Dakota Huang
Dakota Huang

Posted on

Exit-Path Coverage Is the Extract Gate

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"
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)