A tangled export function is not ready for a split.
Freeze stdout, output bytes, and return shape first.
One extract comes after those pins stay green.
Messy repos fail refactors at mixed I/O, not names.
One function prints, writes files, and returns a dict.
Callers depend on all three surfaces at once.
Do not start with a prettier module layout.
Start with a characterization harness around those surfaces.
Keep the first code change smaller than the test net.
Why mixed I/O breaks naive extracts
God functions hide coupling behind a convenient name.
export_bundle looks like one job in the call graph.
It is three jobs with shared mutation and clocks.
Extracting a helper without pins changes silent contracts.
Newline order on stdout is a contract.
CSV byte totals are a contract.
Dict key order can be a contract for JSON dumps.
A refactor that preserves unit tests can still break jobs.
Unit tests often mock away the real surfaces.
Characterization tests keep the surfaces in the room.
The example surface
The listing below is a labeled, unexecuted example.
It mimics a report exporter found in messy repos.
Treat it as a teaching fixture, not production code.
# example_unexecuted: messy_export.py
from __future__ import annotations
import csv
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def export_bundle(
rows: list[dict[str, Any]],
out_dir: str,
options: dict[str, Any],
) -> dict[str, Any]:
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
csv_path = out / "bundle.csv"
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
options["last_export"] = stamp
fieldnames = ["sku", "qty", "note"]
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
written = 0
for row in rows:
writer.writerow(
{
"sku": row.get("sku", ""),
"qty": int(row.get("qty") or 0),
"note": (row.get("note") or "").replace("\n", " "),
}
)
written += 1
print(f"exported {written} rows at {stamp}")
print(f"csv={csv_path.name}")
return {
"rows": written,
"csv": str(csv_path),
"stamp": stamp,
"empty": written == 0,
}
The function mutates options in place.
It also stamps wall-clock time into two surfaces.
Those two facts matter more than the CSV columns.
What to pin before any extract
Pin three surfaces, not the internal helper names.
- Captured stdout text, including trailing newlines.
- Exact file bytes, not parsed CSV rows.
- Return dict shape, types, and stable key order.
Also pin time and working directory when they leak.
datetime.now() must not move between test runs.
Relative output paths must not depend on the runner cwd.
Skip internal private names until those pins exist.
A renamed helper is not a frozen contract.
A SHA-256 of bundle.csv is a frozen contract.
Step 1 — Isolate the process edges
Create a temp directory for every test.
Change directory only inside that test, then restore.
Patch the clock with one fixed timezone-aware instant.
Number the fixtures so later failures map to a surface.
Fixture one owns the clock.
Fixture two owns the temp directory and cwd.
# example_unexecuted: conftest.py
from __future__ import annotations
import os
from datetime import datetime, timezone
from pathlib import Path
import pytest
FIXED_NOW = datetime(2026, 9, 21, 14, 30, 0, tzinfo=timezone.utc)
@pytest.fixture
def frozen_clock(monkeypatch):
class _Clock:
@staticmethod
def now(tz=None):
return FIXED_NOW if tz else FIXED_NOW.replace(tzinfo=None)
monkeypatch.setattr("messy_export.datetime", _Clock)
return FIXED_NOW
@pytest.fixture
def isolated_cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
return tmp_path
Do not patch open for this class of bug.
The file bytes are the product, not an accident.
Path mocks hide the encoding and newline policy.
Step 2 — Write tests named after observables
Name tests after observables, not after hoped design.
test_stdout_summary_matches_pinned_text is a good name.
test_new_formatter_class_is_clean is a bad name.
The harness below is a labeled, unexecuted example.
It pins stdout, file digest, return keys, and mutation.
Run it against the current messy function first.
# example_unexecuted: test_export_bundle_surfaces.py
from __future__ import annotations
import hashlib
from io import StringIO
from contextlib import redirect_stdout
from messy_export import export_bundle
ROWS = [
{"sku": "A-1", "qty": "2", "note": "line\nbreak"},
{"sku": "B-9", "qty": None, "note": "ok"},
]
PINNED_STDOUT = (
"exported 2 rows at 2026-09-21T14:30:00Z\n"
"csv=bundle.csv\n"
)
PINNED_CSV_SHA256 = (
"e3b0c44298fc1c149afbf4c8996fb924"
"replace-this-with-observed-digest"
)
PINNED_KEYS = ["rows", "csv", "stamp", "empty"]
def _digest(path):
data = path.read_bytes()
return hashlib.sha256(data).hexdigest(), len(data)
def test_stdout_summary_matches_pinned_text(frozen_clock, isolated_cwd):
options = {"keep": True}
buf = StringIO()
with redirect_stdout(buf):
export_bundle(ROWS, "out", options)
assert buf.getvalue() == PINNED_STDOUT
def test_csv_bytes_match_pinned_digest(frozen_clock, isolated_cwd):
options = {"keep": True}
with redirect_stdout(StringIO()):
export_bundle(ROWS, "out", options)
digest, size = _digest(isolated_cwd / "out" / "bundle.csv")
assert size > 0
assert digest == PINNED_CSV_SHA256
def test_return_shape_and_option_mutation(frozen_clock, isolated_cwd):
options = {"keep": True}
with redirect_stdout(StringIO()):
result = export_bundle(ROWS, "out", options)
assert list(result) == PINNED_KEYS
assert result["rows"] == 2
assert result["empty"] is False
assert result["stamp"] == "2026-09-21T14:30:00Z"
assert options["last_export"] == result["stamp"]
assert options["keep"] is True
Replace the placeholder digest after the first run.
Do not invent a hash before the function executes.
The first green run is a recording session.
Step 3 — Record goldens from the live mess
Run the tests against the current messy function.
Copy the observed stdout into a literal string.
Hash the file bytes with SHA-256 for a compact pin.
python -m pytest test_export_bundle_surfaces.py -q
python - <<'PY'
from pathlib import Path
import hashlib
p = Path("out/bundle.csv")
raw = p.read_bytes()
print(len(raw), hashlib.sha256(raw).hexdigest())
print(raw)
PY
Do not edit goldens to match a future extract.
The current mess is the specification today.
A failing pin after a tidy rename is a real regression.
Record empty-input and one-row cases as extra pins.
Zero rows still print a summary line.
Zero rows still write a header-only CSV.
Step 4 — Make the smallest safe change
Extract one pure formatter. Leave I/O in place.
Do not move path creation in the same patch.
Do not rename the public function in the same patch.
A safe first extract looks like this proposal.
It is still an unexecuted sketch, not a measured win.
Keep export_bundle as the only public entry.
# proposal_unexecuted: one formatter only
def _csv_row(row: dict) -> dict[str, object]:
return {
"sku": row.get("sku", ""),
"qty": int(row.get("qty") or 0),
"note": (row.get("note") or "").replace("\n", " "),
}
Wire _csv_row into the existing writer loop.
Leave print, mkdir, and return-dict assembly untouched.
Re-run the three surface tests after the extract.
If any pin moves, revert and shrink the extract.
A two-line helper is smaller than a new class.
A new class is not the smallest safe change.
Decision table for the next patch
Use this table before touching a second helper.
Each row is one change budget, not a backlog theme.
| Observed pin movement | Allowed change | Forbidden in same patch |
|---|---|---|
| None | Extract one pure row formatter | Path layout, print text, public name |
| Stdout text only | Revert; inspect print order | Golden rewrite to hide the drift |
| File digest only | Revert; inspect newline and encoding | Switching to parsed-row assertions |
| Return keys or types | Revert; inspect dict construction | JSON serializer extract |
options mutation |
Revert; keep in-place write | New config object in same patch |
| Clock stamp | Restore the frozen clock fixture | Calling now() in the helper |
If two cells would change, split the work.
Two cells means two patches.
Two patches means two green characterization runs.
Where a free coding model can help
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
After the pins are green, a second pass can propose the extract.
MonkeyCode provides free model access and a free server option.
Use that pass to suggest a helper, not to invent tests.
Feed the model the messy function and the pin names.
Ask for the smallest helper that keeps all pins green.
Reject patches that rewrite goldens to stay green.
The tests remain the oracle. The model remains optional.
Local pytest still decides whether the extract is safe.
Skip the model pass when the pins are still red.
Commands that keep the loop honest
Run a narrow file, not the whole suite, during extracts.
Whole-suite noise hides a moved stdout pin.
A 20-second local loop beats a wide unfocused run.
python -m pytest test_export_bundle_surfaces.py -q --tb=short
git diff --stat
git checkout -- messy_export.py # if a pin moved
Commit the tests before the extract commit.
Two commits make revert cheap.
One mixed commit hides which side moved.
Limitations
This workflow does not prove functional correctness.
It proves the current mess did not drift.
Wrong business math can still hash the same way.
Byte pins are brittle across CSV dialects.
Excel-style quoting can move a digest without a real bug.
Document the dialect in the test module docstring.
Stdout pins fail under logging configuration changes.
If another library logs during import, the pin is dirty.
Import the module once in a dedicated test process.
Clock patches can miss time.time() or date.today().
Search the function for every time source before recording.
One unpatched clock makes goldens flaky on Monday.
The method also ignores performance and memory.
A faster extract can still be the wrong extract.
Add a benchmark only after the pins stay green.
Who should not use this approach
Do not use this when the public contract is already typed.
A small pure library with golden APIs needs unit tests.
Characterization pins would freeze accidents as law.
Do not use this on code that must change output today.
If the CSV schema is the work item, update specs first.
Then record new goldens as an intentional change.
Do not use this as a substitute for seed data review.
PII in sample rows will land in the pin files.
Use synthetic SKUs and empty notes in fixtures.
Skip the model pass for security-sensitive exporters.
Free model access is optional tooling, not a reviewer of secrets.
Keep credentials and customer files out of the prompt.
Close the loop
Pin stdout, file bytes, and return shape first.
Extract one pure formatter second.
Leave path I/O and print text for a later patch.
The messy repo gets safer when the net is smaller.
The net is smaller when each pin names one surface.
The extract is safe when every named surface holds.
Top comments (0)