DEV Community

Dakota Huang
Dakota Huang

Posted on

The Smallest Safe Change: Capture Behavior, Refactor, Ship

The smallest safe change is the one you can revert without pain. Characterization tests make that possible. This workflow turns a legacy function into a locked baseline.

The Messy Function

Legacy code is a black box. You cannot trust your reading. You can trust captured behavior.

Here is a typical function. It reads global state. It has hidden fees. Zero tests.

# shipping.py
CONFIG = {"discount": 0.9, "holiday": False}

def shipping_cost(order, customer):
    base = 5.0
    if order["weight"] > 10:
        base += 10.0
    if order.get("expedited"):
        base += 15.0
    if customer["tier"] == "gold":
        base *= CONFIG["discount"]
    if CONFIG["holiday"]:
        base *= 0.95
    return round(base, 2)
Enter fullscreen mode Exit fullscreen mode

You can read it, but you cannot trust your reading. The function depends on a mutable global. One wrong assumption breaks production.

The strategy: capture what it does today. Then change one line and verify.

Step 1: Choose a Capture Source

You need realistic inputs. Use production logs, past test data, or a hand-picked set of edge cases.

Source Pros Cons
Production logs Covers real traffic Needs parsing
Manual examples Fast, targeted Misses unknown branches
Property generation Explores many combos Over-generates noise

For this example, manual examples are enough. Copy them from your ticket descriptions or your reviewer's brain.

Step 2: Instrument and Capture

Wrap the function with a decorator. Every call gets recorded as JSON.

# instrument.py
import json
import functools
from shipping import shipping_cost

def characterize(func, log="capture.jsonl"):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        record = {"args": args, "kwargs": kwargs, "result": result}
        with open(log, "a") as f:
            f.write(json.dumps(record) + "\n")
        return result
    return wrapper

characterized = characterize(shipping_cost)

# Feed realistic inputs
characterized({"weight": 5}, {"tier": "regular"})
characterized({"weight": 15, "expedited": True}, {"tier": "gold"})
characterized({"weight": 11}, {"tier": "silver"})
Enter fullscreen mode Exit fullscreen mode

Open capture.jsonl. You see three records with exact outcomes. That is your evidence.

Expand the capture set if needed. Add more combinations. Capture the global state too if it changes.

Step 3: Generate Tests From Captures

Do not hand-write tests. Generate them from the captured records.

# generate_tests.py
import json

records = [json.loads(line) for line in open("capture.jsonl")]

with open("test_shipping.py", "w") as f:
    f.write("import unittest\nfrom shipping import shipping_cost\n\n")
    f.write("class TestShipping(unittest.TestCase):\n")
    for i, rec in enumerate(records):
        f.write(f"    def test_case_{i}(self):\n")
        f.write(f"        self.assertEqual(shipping_cost(*{rec['args']!r}, **{rec['kwargs']!r}), {rec['result']!r})\n")
    f.write("\nif __name__ == '__main__':\n    unittest.main()\n")
Enter fullscreen mode Exit fullscreen mode

Run it.

python -m unittest test_shipping
Enter fullscreen mode Exit fullscreen mode

If your inputs are representative, you get green. This baseline is your safety net.

Step 4: Lock and Refactor

Now make the smallest possible change. Move the expedited fee into CONFIG. Replace the hardcoded 15.0 with a lookup.

# shipping.py (after change)
CONFIG = {"discount": 0.9, "holiday": False, "expedited_fee": 15.0}

def shipping_cost(order, customer):
    base = 5.0
    if order["weight"] > 10:
        base += 10.0
    if order.get("expedited"):
        base += CONFIG["expedited_fee"]
    if customer["tier"] == "gold":
        base *= CONFIG["discount"]
    if CONFIG["holiday"]:
        base *= 0.95
    return round(base, 2)
Enter fullscreen mode Exit fullscreen mode

Re-run the tests.

python -m unittest test_shipping
Enter fullscreen mode Exit fullscreen mode

Green again. You shipped a safe refactor. Behavior is identical. You only changed the source of a constant.

The Bug You Must Not Fix

You will find bugs while capturing behavior. Ignore them for now. Your job is to preserve behavior, not improve it. Ship the refactor first. Then open a separate ticket for the bug.

If you fix the bug inside the same change, you cannot tell which change broke something. Keep refactor and fix separate. This is non-negotiable.

Where an AI Assistant Helps

This workflow stands alone. But free tooling can compress it. MonkeyCode offers free model access and a free server option. You can use them to draft generator scripts or run the capture in an isolated environment. It is optional.

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

Limitations

Characterization tests only cover observed inputs. Hidden branches stay untested. If production traffic hits an edge case you never captured, your next refactor can still break it.

Nondeterministic behavior is a trap. If your function depends on time or randomness, capture those values explicitly. Otherwise your tests will fail on replay.

Global state is another trap. If CONFIG changes at runtime, your captured inputs are incomplete. Record the state alongside the arguments, or freeze it before capture.

Who Should Not Use This Workflow

Skip this approach in three cases. You already have a contract or spec. Your codebase has comprehensive unit tests. Or your legacy function is badly broken and you actually need to fix behavior, not preserve it.

If you are adding a brand-new feature, write normal tests. Characterization tests are for code you fear to touch.

Conclusion

Characterization-first refactoring is simple. Capture behavior, lock it with tests, then change one line. The smallest safe change is the one you can revert without pain.

Pick one ugly function today. Capture five inputs. Generate tests. Refactor one constant. Your future self will thank you.

Top comments (0)