DEV Community

Dakota Huang
Dakota Huang

Posted on

Characterize One Messy Module Before the First Safe Diff

Characterization tests freeze observed behavior. Then one small refactor stays honest. Skip this order and diffs hide meaning.

A messy module is not a rewrite ticket. It is a behavior surface. Pin that surface before any extract.

Why cleanup fails without pins

Cleanup looks productive on a review screen. Names get shorter. Files look calmer. Callers still break on Tuesday.

Tests written after the refactor describe new code. They do not protect the old contract. That gap is a common production incident.

AI-assisted edits amplify the same gap. A model rewrites structure quickly. It does not know which quirks are load-bearing.

Scope for this workflow

This is a constructed Python example. Treat it as a method, not a memoir. No production metrics are claimed here.

You isolate one module. You record golden traces. You encode those traces as tests. Then you extract one function only.

The messy module under test

The listing below is a proposal. It is not a live production file. Copy it into quote.py for the exercise.

# quote.py — constructed example, not production code
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN

TAX = Decimal("0.0875")
_last = None

def price(raw, vip="0", coupon=None, state="NY"):
    global _last
    n = Decimal(str(raw))
    if vip in (True, "1", "true", "True", 1):
        n = n * Decimal("0.92")
    if coupon:
        n = n - Decimal(str(coupon))
    if n < 0:
        n = Decimal("0")
    if state == "NY":
        n = n + n * TAX
    elif state == "OR":
        pass
    else:
        n = n + n * Decimal("0.06")
    if n == n.to_integral_value():
        out = str(int(n))
    else:
        q = ROUND_DOWN if state == "OR" else ROUND_HALF_UP
        out = str(n.quantize(Decimal("0.01"), rounding=q))
    _last = out
    return out
Enter fullscreen mode Exit fullscreen mode

The function mixes types on purpose. It mutates a module global. Rounding depends on state. String results hide Decimal intent.

Do not tidy this file yet. Tidy work without pins is guesswork.

Step 1: Freeze the public surface

List every caller-visible effect. Write them as numbered facts. Keep the list short.

  1. Return a string, never a Decimal.
  2. Negative totals clamp to zero.
  3. VIP accepts several truthy encodings.
  4. Oregon skips tax and rounds down.
  5. _last stores the last returned string.

That list is the characterization target. Anything else waits for a later change.

Step 2: Capture golden traces

Do not invent expected values. Execute the current function. Record what it actually returns.

The next block is a capture script. Label it unexecuted until you run it locally.

# capture_quotes.py — run against the current quote.py
from quote import price, _last

CASES = [
    {"raw": "10", "vip": "0", "coupon": None, "state": "NY"},
    {"raw": "10", "vip": "1", "coupon": None, "state": "NY"},
    {"raw": "10", "vip": True, "coupon": "1.5", "state": "OR"},
    {"raw": "10", "vip": "true", "coupon": "20", "state": "TX"},
    {"raw": 10.125, "vip": 1, "coupon": 0, "state": "NY"},
    {"raw": "0", "vip": "False", "coupon": None, "state": "OR"},
]

if __name__ == "__main__":
    for i, kwargs in enumerate(CASES):
        value = price(**kwargs)
        print(f"{i}\t{value!r}\t{_last!r}")
Enter fullscreen mode Exit fullscreen mode

Run it once on your copy. Save stdout as quotes.golden.txt. That file is the pin.

python capture_quotes.py | tee quotes.golden.txt
Enter fullscreen mode Exit fullscreen mode

Do not edit golden values by hand. Hand edits reintroduce hope. Re-run capture only when the old contract is meant to change.

Illustrative output from this constructed module follows. Re-run capture on your copy before you trust it.

0   '10.88' '10.88'
1   '10.01' '10.01'
2   '7.70'  '7.70'
3   '0' '0'
4   '10.13' '10.13'
5   '0' '0'
Enter fullscreen mode Exit fullscreen mode

Those six rows are the oracle. They are not a design document. They are what the messy function does today.

Step 3: Encode traces as tests

Load the golden file in pytest. Keep assertions boring. Boring tests survive later extracts.

# test_quote_characterization.py
import ast
from pathlib import Path

from quote import price, _last

CASES = [
    {"raw": "10", "vip": "0", "coupon": None, "state": "NY"},
    {"raw": "10", "vip": "1", "coupon": None, "state": "NY"},
    {"raw": "10", "vip": True, "coupon": "1.5", "state": "OR"},
    {"raw": "10", "vip": "true", "coupon": "20", "state": "TX"},
    {"raw": 10.125, "vip": 1, "coupon": 0, "state": "NY"},
    {"raw": "0", "vip": "False", "coupon": None, "state": "OR"},
]


def load_golden(path="quotes.golden.txt"):
    rows = {}
    for line in Path(path).read_text().splitlines():
        idx, value, last = line.split("\t")
        rows[int(idx)] = (ast.literal_eval(value), ast.literal_eval(last))
    return rows


def test_golden_rows_match_current_price():
    golden = load_golden()
    assert set(golden) == set(range(len(CASES)))
    for i, kwargs in enumerate(CASES):
        value = price(**kwargs)
        expected_value, expected_last = golden[i]
        assert value == expected_value
        assert _last == expected_last
Enter fullscreen mode Exit fullscreen mode

Run the suite before any refactor. A red characterization test means the capture is stale. Fix capture first. Do not start the extract.

python -m pytest test_quote_characterization.py -q
Enter fullscreen mode Exit fullscreen mode

One test function is enough here. More tests can wait. Coverage theater is not the goal.

Step 4: Make the smallest safe change

Only after green tests. Extract rounding. Leave VIP parsing alone. Leave tax branches alone.

The next extract is a proposal. Apply it only on a green characterization suite.

# proposed extract inside quote.py
def _format_money(n, state):
    if n == n.to_integral_value():
        return str(int(n))
    q = ROUND_DOWN if state == "OR" else ROUND_HALF_UP
    return str(n.quantize(Decimal("0.01"), rounding=q))
Enter fullscreen mode Exit fullscreen mode

Replace the inline formatting block with _format_money(n, state). Keep _last assignment in price. Do not rename price. Do not change argument types.

Re-run the same pytest command. If it fails, revert the extract. The golden file stays the authority.

Step 5: Stop after one behavior move

A safe refactor changes structure. It does not change meaning. One extract per cycle keeps diffs reviewable.

If you also want Decimal returns, that is a new contract. New contracts need new tests. They do not belong in this cycle.

Record a short decision log beside the diff. Note what stayed frozen. Note what moved. Note what you refused to touch.

Using a free model only for scaffolds

A coding model can draft test stubs from quotes.golden.txt. It must not invent expected strings. You paste capture output. The model only rearranges it.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access can draft the pytest wrapper. Its free server option can run that wrapper. Neither replaces the golden file you captured locally.

Keep the model off the oracle path. If the model writes assert value == "10.00" without a trace, discard the stub. Characterization fails when hoped values leak in.

Decision table for messy modules

Use this table before you open an editor. Pick one row. Do not mix rows in one diff.

Situation Action Why
Callers depend on quirks Characterize, then extract Quirks are the contract
Module is dead code Delete with search evidence Tests would freeze waste
Contract must change Write new examples first Golden traces would lie
No executable path Do not refactor yet You cannot pin behavior
Need types and names only Smallest extract after green Meaning stays still

If two rows seem true, split the work. Dead-code deletion is not a characterization task. Contract change is not an extract.

What this method will not do

Characterization does not prove correctness. It proves stability. Wrong rounding stays wrong if callers already depend on it.

This method also misses hidden I/O. The sample has no network. Real modules may. Add collaborator spies before you extract those paths.

Do not use this approach for a greenfield API. There is no old behavior to freeze. Write examples from the intended contract instead.

Do not use it when you cannot run the module. Frozen binaries without a harness yield theater. Find a runner first.

Skip it for one-off scripts you will delete. The suite cost exceeds the risk. Deletion is the smaller change.

Review checklist before you merge

  1. Golden file came from execution, not from chat.
  2. Pytest was green before the extract.
  3. The diff touches one behavior seam.
  4. Return types and error strings stayed identical.
  5. Pytest is still green after the extract.

Fail any item and revert. A pretty extract that fails item four is a rewrite. Treat it as a product change.

Closing constraint

Messy repos punish ambition. They reward a frozen trace. Six boring assertions beat a tasteful rewrite.

Run capture. Encode the file. Extract one function. Stop. If you need a scaffold for that pytest wrapper, MonkeyCode's free model access and free server option can host the mechanical part while you keep the golden values local.

Top comments (0)