Do not extract a helper from mixed side effects.
Freeze logs, cache keys, and return payloads together.
Characterization tests must own that mixed side-effect ledger.
A gold ledger makes the later extract boring.
A missing ledger makes the later extract a guess.
The failure you actually ship
Messy production modules rarely expose one clean observable.
They log lines, write cache keys, and return nested dicts.
Callers then fail on messages, not on return values.
Tests that assert only the return miss the drift.
Log order changes while cache keys silently shuffle underneath.
Downstream alerts then fire on the moved log lines.
The extract still looks green in a shallow unit test.
Treat those three columns as one contract.
Do not split a helper across an unlocked contract.
The ledger in one table
Define one ledger row per public entry call.
Each row stores logs, cache keys, and the return.
Keep list order and do not sort away the bug.
| Column | Freeze this | Fail the extract if |
|---|---|---|
logs |
exact message list and order | one line moves or rewords |
cache_keys |
insertion order of written keys | a key appears, vanishes, or swaps |
cache_values |
nested values behind those keys | stored warnings, scores, or flags drift |
return |
nested keys, types, and values | any key or type changes |
This three-column table is the whole method.
Pretty internals do not replace those three columns.
Cache key lists are not enough for this module.
The hit path copies the stored payload into the return.
A helper that rewrites warnings can mutate stored values silently.
Illustrative messy module
The module below is a labeled illustrative example.
It is not a production service or a measured benchmark.
It mixes logging, an in-process cache, and a return payload.
# status_report.py
from __future__ import annotations
def build_status_report(
user: dict,
flags: dict,
cache: dict,
log: list,
) -> dict:
uid = user.get("id")
plan = user.get("plan") or "free"
region = user.get("region") or "us"
verbose = bool(flags.get("verbose"))
refresh = bool(flags.get("refresh"))
log.append(f"start uid={uid} plan={plan}")
cache_key = f"status:{uid}:{region}"
if cache_key in cache and not refresh:
log.append(f"cache_hit key={cache_key}")
payload = dict(cache[cache_key])
payload["source"] = "cache"
if verbose:
log.append("verbose cached payload copied")
return payload
log.append(f"cache_miss key={cache_key}")
warnings: list[str] = []
score = 0
if plan == "free":
score += 1
warnings.append("limited")
log.append("plan_rule free")
elif plan == "pro":
score += 5
log.append("plan_rule pro")
else:
score += 3
warnings.append("unknown_plan")
log.append(f"plan_rule other={plan}")
if region not in ("us", "eu"):
warnings.append("region")
log.append(f"region_rule odd={region}")
score -= 1
else:
log.append(f"region_rule ok={region}")
if flags.get("beta"):
score += 2
log.append("flag_rule beta")
payload = {
"uid": uid,
"plan": plan,
"region": region,
"score": score,
"warnings": warnings,
"source": "live",
}
cache[cache_key] = {
"uid": uid,
"plan": plan,
"region": region,
"score": score,
"warnings": list(warnings),
"source": "live",
}
log.append(f"cache_write key={cache_key}")
if verbose:
log.append(f"verbose score={score}")
return payload
Notice the cache stores source=live even for later hits.
Notice verbose logs fire after the cache branch.
Those details are the contract, not harmless accidents.
Characterization harness
Pin a fixture table and capture the full ledger.
Compare the observed JSON against the committed gold file.
Do not assert internal helper names yet.
Those names do not exist before the extract.
The public entry and the gold file remain the API.
# test_status_report_char.py
from __future__ import annotations
import json
from pathlib import Path
from status_report import build_status_report
GOLD = Path(__file__).with_name("status_report_ledger.gold.json")
CASES = [
{
"name": "free_us_live",
"user": {"id": "u1", "plan": "free", "region": "us"},
"flags": {"verbose": False, "refresh": False, "beta": False},
"seed_cache": {},
},
{
"name": "free_us_hit",
"user": {"id": "u1", "plan": "free", "region": "us"},
"flags": {"verbose": True, "refresh": False, "beta": False},
"seed_cache": {
"status:u1:us": {
"uid": "u1",
"plan": "free",
"region": "us",
"score": 1,
"warnings": ["limited"],
"source": "live",
}
},
},
{
"name": "pro_ap_beta_refresh",
"user": {"id": "u2", "plan": "pro", "region": "ap"},
"flags": {"verbose": True, "refresh": True, "beta": True},
"seed_cache": {
"status:u2:ap": {
"uid": "u2",
"plan": "pro",
"region": "ap",
"score": 99,
"warnings": [],
"source": "live",
}
},
},
{
"name": "unknown_plan_empty_user",
"user": {"id": None, "plan": "", "region": ""},
"flags": {},
"seed_cache": {},
},
]
def capture(case: dict) -> dict:
log: list[str] = []
cache = dict(case["seed_cache"])
result = build_status_report(case["user"], case["flags"], cache, log)
return {
"name": case["name"],
"logs": log,
"cache_keys": list(cache.keys()),
"cache_values": cache,
"return": result,
}
def canonical(data):
return json.loads(json.dumps(data, sort_keys=True))
def test_ledger_matches_gold():
observed = [capture(case) for case in CASES]
if not GOLD.exists():
GOLD.write_text(json.dumps(observed, indent=2, sort_keys=True) + "\n")
raise AssertionError(f"wrote {GOLD}; rerun after review")
gold = json.loads(GOLD.read_text())
assert canonical(observed) == canonical(gold)
canonical() sorts object keys only, never list order.
Log lines and cache_keys stay ordered sequences in JSON.
That split is required or the gold file hides shuffles.
pytest test_status_report_char.py -q
# first run writes status_report_ledger.gold.json
# open the file, read every log line, then rerun
pytest test_status_report_char.py -q
Review the gold file as a product document.
Commit it only after a human reads every column.
Seven steps, in this order
1. Branch the messy entry
Copy the messy module onto a dedicated branch first.
Do not rename symbols during this copy step.
Keep the public function signature frozen for now.
2. Inject the three sinks
Pass log and cache into the public entry.
Do not patch process globals after the first gold run.
Hidden sinks make the captured ledger a lie.
3. Write the fixture table
Cover hit, miss, refresh, verbose, and empty fields.
Four cases beat one happy path with a pretty extract.
Name each case so names index the gold file.
4. Gold the ledger file
Run the public entry with that frozen fixture table.
Write the observed ledger to a committed JSON file.
Read logs, keys, and nested returns before you commit.
5. Draft extra rows without touching production
A free coding model can propose extra input rows.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode currently offers free model access and a free server option.
Use those options only to draft cases and run pytest.
Do not ask the model to rewrite the messy module first.
Paste the public signature and the gold JSON schema only.
The prompt below is labeled unexecuted example text.
Propose eight additional CASES rows for this characterization table.
Keep keys name, user, flags, seed_cache.
Do not invent new public functions.
Do not rewrite status_report.py.
Prefer empty strings, missing flags, cache hits, and refresh.
Run the suite on a spare server if the laptop is busy.
The gold file still lives in your repository either way.
6. Extract one private helper
Extract one private helper with one narrow responsibility.
Keep every ledger column identical after that extract.
Stop after one helper and do not chain extra cleanups.
Safe extract in this module: plan scoring only.
def _plan_score(plan: str, log: list) -> tuple[int, list[str]]:
if plan == "free":
log.append("plan_rule free")
return 1, ["limited"]
if plan == "pro":
log.append("plan_rule pro")
return 5, []
log.append(f"plan_rule other={plan}")
return 3, ["unknown_plan"]
Call it from the same branch that already writes the cache.
Do not move cache writes into that helper in this step.
7. Re-run, then stop
Run the same characterization suite after the extract.
Revert the extract if any ledger column changes.
If the gold file matches, the extract is done.
pytest test_status_report_char.py -q
git diff -- status_report.py status_report_ledger.gold.json
A clean gold diff is the only merge signal you need.
Do not celebrate a prettier helper with a dirty ledger.
How to read a ledger failure
When pytest fails, do not reread the helper first.
Diff the gold JSON column by column in this order.
- Compare
logsand restore any moved line. - Compare
cache_keysand restore insertion order. - Compare
cache_valuesbefore you trust the return. - Compare
returnlast, after sinks already match.
pytest test_status_report_char.py -q
git diff -- status_report_ledger.gold.json
# if you regenerated by mistake, restore gold and fix code
git checkout -- status_report_ledger.gold.json
Never accept a regenerated gold file to hide the extract.
Gold updates belong to intentional behavior changes only.
Helper extraction is not an intentional behavior change.
Decision table: extract or wait
| Observation after the candidate extract | Action |
|---|---|
| Gold JSON unchanged | Keep the extract |
| Logs reordered, return identical | Revert. Split was not safe |
| Cache key renamed for clarity | Revert. Callers still use the old key |
| Warnings list sorted | Revert. Order is part of the contract |
| New helper plus a second cleanup | Revert the second cleanup |
Wait when two ledger columns still move together.
Extract when one column is boring and independently named.
That phrase means the helper does not need the cache.
What this workflow will not do
It will not prove the messy module is correct.
It only proves the extract did not change observed behavior.
Wrong gold files freeze wrong behavior with extra ceremony.
It will not replace typed domain tests forever.
Add intent tests after the ledger stops moving.
Do not skip the ledger because intent tests feel adult.
This workflow will not survive hidden I/O sinks.
Network calls, clocks, and process-wide loggers leak.
Inject those sinks before you gold the file.
Who should not use this approach
Do not use this on a greenfield module with no callers.
Write intent tests and types there instead.
Skip the gold JSON on brand new modules.
Do not use this to justify a large rewrite in one branch.
Allow one helper per cycle on that branch.
Mint new gold only when behavior must change.
Do not outsource gold review to any coding model.
Models may draft extra rows for the table.
Humans still accept every gold column in review.
Limits of the optional model loop
Free model access does not pin your public contract.
A free server does not replace the committed gold file.
Both extras remain optional for this workflow.
The committed ledger is not optional at all.
Discard generated cases that invent new public keys.
Discard generated cases that require extra process monkeypatches.
If a case cannot run against the current signature, drop it.
This workflow stays useful with no vendor in the loop.
Delete the model step without losing the method.
Keep pytest and the committed JSON gold file.
The next extract starts only after that file is quiet.
Top comments (0)