Do not start a messy refactor with a rewrite.
Pin current outputs with characterization tests first.
Then change one seam and rerun the harness.
A tangled closer mixes parse, math, files, and prints.
One extract can alter rounding or newline handling.
Golden snapshots catch that drift before callers fail.
The failure mode
Most messy modules have no contract tests.
Engineers extract helpers from names, not outputs.
Return dicts stay stable while file bytes shift.
CSV money fields are a common silent break.
Python banker's rounding surprises many later extract patches.
A clean helper may switch to always-up rounding.
Stdout order can swap after a cosmetic cleanup.
JSON separators can change from spaced to compact.
Callers that hash output files will then fail.
You need pins on every observable channel.
Private function names are not observable contracts.
Do not assert on them during this phase.
What to pin
Characterization tests freeze today's observable behavior only.
They do not claim the behavior is right.
Fix semantics after the module has clear seams.
Record these four channels for a typical closer.
- Return payload shape and numeric field values.
- Exact output file bytes, including trailing newlines.
- Stdout text with any clocks stripped out.
- Exception class and message text for bad rows.
Drop wall-clock timestamps from the golden files.
Replace them with a fixed token during capture.
Otherwise every run produces a new snapshot.
Module-level caches are a fifth hidden channel.
Reset them in setUp before each test case.
Leftover keys will poison later golden assertions.
The messy module
The listing below is a stand-in closer.
Treat it as untested production Python code.
Do not clean it before the first pins.
# sales_close.py
from __future__ import annotations
import json
from pathlib import Path
CACHE: dict[str, float] = {}
def close_shift(log_path: str, out_path: str, tax_bps: int = 825) -> dict:
CACHE.clear()
gross = 0.0
n = 0
lines = Path(log_path).read_text().split("\n")
for raw in lines:
if raw.strip() == "" or raw.startswith("#"):
continue
parts = raw.split(",")
sku = parts[0].strip()
qty = int(parts[1])
price = float(parts[2])
line = round(qty * price, 2)
CACHE[sku] = CACHE.get(sku, 0.0) + line
gross = round(gross + line, 2)
n += 1
tax = round(gross * tax_bps / 10000.0, 2)
net = round(gross + tax, 2)
payload = {
"count": n,
"gross": gross,
"tax": tax,
"net": net,
"by_sku": dict(CACHE),
}
text = json.dumps(payload, indent=2, sort_keys=True)
Path(out_path).write_text(text + "\n")
print("CLOSED", n, "lines")
return payload
Note three landmines before any helper extract.
Blank lines at EOF still split into empties.
The hash prefix test does not skip indented comments.
Built-in round follows binary float rules, not decimal textbooks.
Pin the runtime result instead of a textbook table.
A later Decimal helper would be a behavior change.
Characterization harness
Keep golden files next to the tests, not tmp.
Commit them so reviews can see byte diffs.
Run the suite with a frozen working directory.
# test_sales_close_char.py
from __future__ import annotations
import io
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from tempfile import TemporaryDirectory
import sales_close
FIXTURE = """# morning
A,2,1.25
B,1,10
A,1,3.50
# trailing
"""
GOLDEN_DIR = Path(__file__).parent / "goldens"
class CloseShiftCharacterization(unittest.TestCase):
def setUp(self) -> None:
sales_close.CACHE.clear()
def _run(self, body: str, tax_bps: int = 825) -> tuple[dict, bytes, str]:
with TemporaryDirectory() as tmp:
root = Path(tmp)
log = root / "shift.log"
out = root / "shift.json"
log.write_text(body)
buf = io.StringIO()
with redirect_stdout(buf):
result = sales_close.close_shift(str(log), str(out), tax_bps)
return result, out.read_bytes(), buf.getvalue()
def test_happy_path_return_and_bytes(self) -> None:
GOLDEN_DIR.mkdir(exist_ok=True)
result, data, stdout = self._run(FIXTURE)
self.assertEqual(result["count"], 3)
self.assertEqual(stdout, "CLOSED 3 lines\n")
golden = GOLDEN_DIR / "happy_path.json"
if not golden.exists():
golden.write_bytes(data)
self.fail("wrote golden; rerun to assert bytes")
self.assertEqual(data, golden.read_bytes())
def test_indented_comment_is_not_skipped(self) -> None:
body = " # not a comment\n"
with self.assertRaises(IndexError):
self._run(body)
def test_exact_binary_price_stays_stable(self) -> None:
result, _, _ = self._run("A,1,1.25\n")
self.assertEqual(result["gross"], 1.25)
The first test writes goldens on a cold run.
That write is deliberate for the initial capture.
Never accept a golden you did not open.
Inspect goldens/happy_path.json before the commit.
Confirm count, by_sku, and trailing newline by eye.
Then rerun until the fail-on-write path is gone.
Numbered workflow
Follow this order and skip none of the steps.
- Copy one real production log into the fixtures directory.
- Strip secrets but keep the original messy whitespace intact.
- Add the harness and run once to emit goldens.
- Open the golden JSON and check rounding by hand.
- Commit tests, fixtures, and goldens in one change.
- Extract one helper while keeping the public signature.
- Re-run the suite and revert if bytes drift.
Do not extract parse and round in one patch.
One seam per patch keeps the diff reviewable.
Reviewers can then match bytes to the helper.
Smallest safe change
Extract rounding only and leave parse plus I/O.
The public close_shift signature must stay frozen.
def _round_money(value: float) -> float:
return round(value, 2)
def close_shift(log_path: str, out_path: str, tax_bps: int = 825) -> dict:
CACHE.clear()
gross = 0.0
n = 0
lines = Path(log_path).read_text().split("\n")
for raw in lines:
if raw.strip() == "" or raw.startswith("#"):
continue
parts = raw.split(",")
sku = parts[0].strip()
qty = int(parts[1])
price = float(parts[2])
line = _round_money(qty * price)
CACHE[sku] = CACHE.get(sku, 0.0) + line
gross = _round_money(gross + line)
n += 1
tax = _round_money(gross * tax_bps / 10000.0)
net = _round_money(gross + tax)
payload = {
"count": n,
"gross": gross,
"tax": tax,
"net": net,
"by_sku": dict(CACHE),
}
text = json.dumps(payload, indent=2, sort_keys=True)
Path(out_path).write_text(text + "\n")
print("CLOSED", n, "lines")
return payload
Re-run the unittest module with verbose output enabled.
The golden bytes must match the committed file.
Stdout must still equal the pinned CLOSED line.
If file bytes change, the extract is already wrong.
Today's round result is the contract, not a wish.
Do not fix rounding inside the same patch.
Decision table
Map each observable signal to a stop rule.
| Signal | Continue when | Stop if |
|---|---|---|
| Return dict | Keys and numbers match | Extra key or type appears |
| File bytes | Exact match to golden | Indent, order, or newline shifts |
| Stdout | Exact string match | Extra space or extra log line |
| Exception | Same class as before | Message text or class changes |
| CACHE |
by_sku matches return |
Insertion order or leftover keys |
Use the table during review, not after merge.
A red cell means revert the helper extract.
Do not update goldens to hide a surprise.
Using a free model and a free server
A free coding model can draft the first harness.
It should not invent new rounding or skip rules.
Feed it the messy function and the pin list.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option.
Use the model to propose characterization tests only.
Use the free server when local interpreters conflict.
Paste the messy function and ask for pins, not rewrites.
Require the model to assert raw file bytes.
Pretty JSON equality misses separator and newline drift.
Run the emitted tests on the free server.
Download the golden files back into git.
The repo remains the source of truth here.
Reject patches that rename CACHE in the same step.
Reject patches that switch json dumps separators.
Those edits are new behaviors, not safe extracts.
Limitations
Characterization tests lock today's bugs in place.
Indented comments still crash after the rounding extract.
That crash is now a pinned, reviewed contract.
Do not use goldens as a long-term design spec.
They expire the moment you change the format.
Update them in a dedicated, reviewed follow-up commit.
Binary files need a different pin strategy altogether.
A hash plus size can work for binaries.
This closer is text, so full bytes are fine.
A free model can miss hidden side-effect channels.
Module globals and print calls are easy to skip.
The checklist above exists to close that gap.
A free server does not replace project CI jobs.
It only unblocks a dirty local Python toolchain.
Merge checks still run in your normal pipeline.
Who should skip this
Skip this if the module has no callers yet.
Write real unit tests and a clear spec instead.
There is nothing useful to characterize in that case.
Skip this for security-sensitive parsers and decoders.
Pinned crashes are not a useful threat model.
Fuzzing and reviewed grammars belong in that work.
Skip this if you must change rounding now.
Do that in a behavior-change commit first.
Then characterize the new contract, not the old.
Commands
python -m unittest test_sales_close_char.py -v
git add sales_close.py test_sales_close_char.py goldens
git diff --check
Keep the extract diff under one helper function.
If the diff touches parse and I/O, split it.
Characterization only pays off with small seams.
The closer is still messy after one extract.
That outcome is expected for this workflow.
Rewrite later, when the tests already exist.
If you run this harness on a free server, commit goldens locally first.
Top comments (0)