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)
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()
Run it once. The golden file becomes your contract.
python snapshot.py
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"]
Run it.
pytest test_compute_total.py
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.
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
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)
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)