DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin the Mess: Characterization Tests Before You Refactor a Legacy Function

Untested legacy functions are landmines. A characterization test pins their actual behavior. Do that before you refactor.

You do not need to understand the whole repo. You need one function's contract. Here is a repeatable 30-minute workflow. It uses characterization tests, a free server, and a coding model to generate edge cases. The result is a safety net for your next smallest change.

The Setup

Take a function with many callers. Example:

# legacy.py
def compute_total(price, discount_code, region):
    if discount_code == "SAVE10":
        price = price * 0.9
    elif discount_code == "FLAT5":
        price = price - 5
    if region == "EU":
        return round(price * 1.2, 2)
    return round(price, 2)
Enter fullscreen mode Exit fullscreen mode

Nobody knows all rules. Twelve call sites depend on this. There are no tests. Data-driven work starts by recording reality.

Step 1: Record Current Behavior

Characterization tests do not assert what should happen. They assert what happens now. Write a small script that samples real inputs from your codebase or from an API log. If you have no logs, use a curated list of typical and boundary values.

# snapshot.py
import json

def compute_total(price, discount_code, region):
    # paste the legacy function here
    ...

def sample_inputs():
    return [
        {"price": 100, "discount_code": None, "region": "US"},
        {"price": 100, "discount_code": "SAVE10", "region": "US"},
        {"price": 100, "discount_code": "FLAT5", "region": "EU"},
        {"price": 0, "discount_code": None, "region": "EU"},
        {"price": 99.99, "discount_code": "SAVE10", "region": "US"},
        {"price": -10, "discount_code": None, "region": "US"},
    ]

def main():
    results = []
    for params in sample_inputs():
        out = compute_total(**params)
        results.append({**params, "output": out})
    with open("golden.json", "w") as f:
        json.dump(results, f, indent=2)

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it once. The golden file becomes your contract.

python snapshot.py
Enter fullscreen mode Exit fullscreen mode

Do not change the function yet. If an output looks wrong, that is still the current contract. Fix the product later, not during a refactor.

Step 2: Turn Golden Data Into Tests

Convert captured records into executable assertions. A simple pytest file works.

# test_compute_total.py
import json
from legacy import compute_total

def test_current_behavior():
    with open("golden.json") as f:
        cases = json.load(f)
    for case in cases:
        params = {k: v for k, v in case.items() if k != "output"}
        assert compute_total(**params) == case["output"]
Enter fullscreen mode Exit fullscreen mode

Run it.

pytest test_compute_total.py
Enter fullscreen mode Exit fullscreen mode

Green means you have preserved current behavior. Red means your snapshot script or the function copy is wrong.

Step 3: Generate More Edge Cases With a Free Model

A curated list misses surprises. Use MonkeyCode's free model access to generate boundary inputs. Give the model the signature and your current samples. Ask for inputs likely to break a naive implementation.

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

Example prompt:

Function: compute_total(price, discount_code, region)
Known cases: (list them)
Generate 20 more inputs including negatives, decimals, None, unknown codes, and mixed regions.
Enter fullscreen mode Exit fullscreen mode

Treat the model suggestions as a starting point. Do not trust them blindly. Add plausible ones to sample_inputs(). Rerun snapshot.py and the test. Unexpected failures are gold: they expose undocumented branches.

Step 4: Run the Test Suite on a Free Server

You do not need a heavy CI setup for a 30-minute refactor. MonkeyCode's free server option can run your tests. Push your test file and a minimal requirements file to a repo. The server executes this:

pip install -r requirements.txt
pytest test_compute_total.py
Enter fullscreen mode Exit fullscreen mode

You get a pass/fail signal without burning local cycles. Keep the suite small so it finishes quickly.

Step 5: Make the Smallest Safe Change

Now you have a safety net. Pick one branch to clean up. Replace a magic string, extract a helper, or rename a variable. Do not touch behavior.

# after
def compute_total(price, discount_code, region):
    price = apply_discount(price, discount_code)
    return apply_region_tax(price, region)
Enter fullscreen mode Exit fullscreen mode

Run the suite again. Green means you preserved behavior. Red means you accidentally changed something. Read the diff to confirm.

Characterization vs. Spec Test: A Decision Table

Situation Use characterization Use spec test
No tests, many callers Yes No
Behavior is known and desired No Yes
Bugs must be preserved for now Yes No
New feature required No Yes
Model-generated edge cases Yes Only after you verify expected values

This table helps you pick the right tool before starting.

Limitations

  • Characterization tests are not specifications. They capture current behavior, which may include bugs.
  • Golden files become stale. Update them deliberately when behavior changes.
  • AI-generated edge cases can be wrong. Verify them against the real function.
  • Free server resources are finite. Do not run heavy milliseconds-long suites for hours.
  • This workflow does not cover side effects, IO, or non-deterministic functions.

Who Should Not Use This

  • Teams without a test runner: learn pytest first.
  • Developers who need new behavior, not preservation: write spec tests instead.
  • Signatures with random or time-based inputs: characterization tests will flake.

The Takeaway

Characterization tests are refactor insurance. Generate edge cases with a free model. Run them on a free server. Then make the smallest change.

Pin the mess first. The refactor becomes boring. That is the goal.

Top comments (0)