DEV Community

Dakota Huang
Dakota Huang

Posted on

Gold the Side-Effect Ledger Before One Helper Extract

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

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

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

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

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

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

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.

  1. Compare logs and restore any moved line.
  2. Compare cache_keys and restore insertion order.
  3. Compare cache_values before you trust the return.
  4. Compare return last, 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
Enter fullscreen mode Exit fullscreen mode

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)