跳到正文
DEV Community· Dakota Huang·· 3 小時前AI 評分56

棘手重構實踐:記錄呼叫順序,而非返回值

Record the Call Order, Not the Return Value: A Ledger Gate for Messy Refactors

AI 導讀

作者提出一套用於模組抽取的測試方法,按順序記錄外部呼叫及其參數摘要,再比較重構前後的清單。這種呼叫帳本可捕捉返回值斷言可能漏掉的呼叫重排、批次查詢變成多次單筆查詢,以及重試導致的重複提交;文章亦示範固定輸入、人工檢查 golden file,並故意加入重複呼叫確認測試會失敗。

正文

Return-value snapshots hide the contract that breaks. Messy repos usually depend on ordered side effects, not on the shape of a result. So before you extract a module, pin the call ledger. Then make the smallest change that keeps the ledger byte-identical.

This is the gate I use when a function touches a database client, a queue, and an HTTP client at once. The trick is boring: wrap every boundary, record (op, args-digest) in order, and compare the list before and after the move.

Why value assertions miss the real contract

A characterization test that asserts result == {...} passes on many wrong implementations. It says nothing about call order, duplicate calls, or retries. Those are exactly what a bad extraction changes.

Three failure modes slip past value tests:

  1. Reordering. The new module fetches the customer after the invoice rows. Result identical. Lock contention not identical.
  2. Fan-out drift. One batched query becomes N single-row queries. Values match, latency does not.
  3. Retry leakage. The wrapper now retries a commit, so the ledger shows two commits instead of one.

A call ledger catches all three with one comparison.

Step 1: Find the boundaries worth recording

Do not guess. Count call sites by method name first.

# top boundary-ish method names in your app code
grep -rhoE "\.[a-z_]+\(" --include="*.py" app \
  | sort | uniq -c | sort -rn | head -25

Keep the methods that talk to something you do not own: execute, fetchall, commit, send, publish, post, open. Drop pure helpers. You want the smallest boundary set that still explains the function's behavior.

If a function has more than three unrelated boundaries, that is your first finding. Split before you extract.

Step 2: Write the recorder

One small class, no framework, no plugins.

# ledger.py
import functools
import hashlib
import json


def _digest(value, limit=120):
    raw = json.dumps(value, sort_keys=True, default=str)[:limit]
    return hashlib.sha256(raw.encode()).hexdigest()[:12]


class Ledger:
    def __init__(self):
        self.entries = []

    def record(self, op, args, kwargs):
        self.entries.append({
            "op": op,
            "args": [_digest(a) for a in args],
            "kwargs": {k: _digest(v) for k, v in sorted(kwargs.items())},
        })

    def wrap(self, target, name=None):
        prefix = name or type(target).__name__
        ledger = self

        class Recorder:
            def __getattr__(self, attr):
                inner = getattr(target, attr)
                if not callable(inner):
                    return inner

                @functools.wraps(inner)
                def call(*args, **kwargs):
                    ledger.record(f"{prefix}.{attr}", args, kwargs)
                    return inner(*args, **kwargs)

                return call

        return Recorder()

Two deliberate choices. Arguments are hashed, so the golden file stays small and no secrets land in git. Return values are passed through untouched, so the code under test behaves normally.

__getattr__ only intercepts attribute access on the wrapper. If your code stores the client on self at construction, wrap it there, not later.

Step 3: Capture the ledger on the current code

The first capture must be deterministic. Run it twice and diff. If it differs, fix the input, not the assertion.

# tests/test_invoice_ledger.py
import json
from pathlib import Path
from ledger import Ledger
from app.invoice_service import InvoiceService
from tests.fakes import FakeRepo, FakeMailer

GOLDEN = Path("tests/golden/invoice.ledger.json")


def build_ledger():
    ledger = Ledger()
    service = InvoiceService(
        repo=ledger.wrap(FakeRepo(rows=ROWS), name="repo"),
        mailer=ledger.wrap(FakeMailer(), name="mailer"),
    )
    service.build(customer_id=7)
    return ledger.entries


def test_ledger_matches_golden():
    assert build_ledger() == json.loads(GOLDEN.read_text())

Generate the golden file once, then review it by hand. Read the ops top to bottom and ask whether each one is a contract or an accident. Delete the accidents now, before the refactor, so the gate stays honest.

Step 4: Keep the ledger stable

Three things destabilize a ledger. Mask them at the boundary, not in the assertion.

Source of noise Fix
Generated ids, timestamps Pass fixed values through the fake
Dict ordering in kwargs sort_keys=True in the digest
Set iteration in args Convert to sorted list before the call

Never normalize inside _digest to make a diff pass. That is how a gate becomes decoration.

Step 5: Make the smallest safe change

Now extract. One function, one commit, ledger unchanged.

git switch -c extract-invoice-build
python -m pytest tests/test_invoice_ledger.py -q   # green before
# move the function, adjust imports only
python -m pytest tests/test_invoice_ledger.py -q   # must stay green
git diff --stat

If the ledger diff appears, stop. Either the move was not behavior-preserving, or the ledger encoded an accident. Decide which, in that order.

Step 6: Prove the gate can fail

A gate you have never seen fail is a guess. Break the new code on purpose.

# temporary, do not commit
service.build(customer_id=7)
service.mailer.send_invoice(customer_id)   # duplicate send

Run the test. It must fail with one extra mailer.send_invoice entry. Then revert and confirm green. Two minutes, and you now know the gate has teeth.

Step 7: Retire it deliberately

Ledger tests are scaffolding, not architecture. Once the module boundary is stable, delete the golden file entries that no longer describe a real contract. Keep the ones that do, and convert them to named assertions with comments explaining why order matters.

A ledger that nobody has reviewed in six months is a maintenance tax with no signal.

Where a free-tier model actually helps

Two parts of this workflow are mechanical: writing the wrapper adapter for an unfamiliar client library, and reproducing the capture in a clean environment. That is where I use MonkeyCode's free model access and free server option, both operator-supplied as of this writing.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Concretely, I paste the client's public method signatures and ask for a Recorder adapter that preserves pass-through semantics. I read the diff, keep what compiles, and hand-check ordering. The model drafts boilerplate; the golden file decides truth. The free server option is useful for the second capture, since a fresh environment catches ambient state my laptop hides. Treat that environment as untrusted: fake credentials, fake data, no real endpoints. Verify every generated line against the library source before it reaches your repo.

Do not use this for anything where the model output is the artifact. Here the artifact is a reviewed golden file.

Who should skip this

  • Codebases with fewer than three boundary calls per function. Plain assertions are cheaper.
  • Teams without a fake for the boundary. Build the fake first, or the ledger will encode network noise.
  • Hot paths where call order is genuinely unspecified. Pin a multiset of ops instead of a sequence.
  • Anyone who cannot review a golden file by hand. Unreviewed goldens launder bugs into tests.

The payoff is narrow and real: you can move code in a repo you do not fully understand, and prove the behavior did not drift. One ordered list of hashed calls is usually enough.

Capture the ledger. Move one function. Diff the order. Ship.

來源:DEV Community · dev.to