Refactoring a legacy function without an oracle is a guess. Characterization tests are the oracle. But AI-generated tests can be weak. Mutation testing proves they work.
Here is a reproducible 30-minute workflow. I used MonkeyCode's free model access and a free server to run everything. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The Problem
You have a messy function. It has high fan-in. It formats money, applies discounts, and handles currency rounding. Nobody wrote tests. Every change feels like roulette.
Direct refactoring is not safe. You need a net. That net is a characterization test: it captures current behavior, not intended behavior.
AI can write that test quickly. But AI also writes tautologies. A test that passes both before and after a broken refactor is fake. Mutation testing catches fake tests.
The Workflow
1. Pick the Target
Choose a function with high fan-in and low test coverage. Here is the one I used:
def calculate_grand_total(items, discount, currency_round=True):
subtotal = 0
for item in items:
if item.get('taxable'):
subtotal += item['price'] * (1 + item['tax_rate'])
else:
subtotal += item['price']
subtotal = subtotal * (1 - discount)
if currency_round:
return round(subtotal, 2)
return subtotal
It looks simple. It has hidden edge cases: missing keys, zero discount, negative prices.
2. Ask AI for Characterization Tests
Do not ask for new features. Ask for a test that locks current behavior. I used this prompt:
Write pytest tests for this function. They must pass with the current implementation.
Include edge cases for empty list, missing 'taxable', zero discount, negative prices.
MonkeyCode returned a test file. The core tests looked like this:
import pytest
from legacy import calculate_grand_total
def test_empty_items():
assert calculate_grand_total([], 0.1) == 0.0
def test_taxable_item_with_rate():
items = [{'price': 100, 'taxable': True, 'tax_rate': 0.2}]
assert calculate_grand_total(items, 0) == 120.0
def test_missing_taxable_defaults_to_no_tax():
items = [{'price': 100}]
assert calculate_grand_total(items, 0) == 100.0
def test_discount_applies_after_subtotal():
items = [{'price': 200, 'taxable': False}]
assert calculate_grand_total(items, 0.25) == 150.0
def test_negative_price_allowed():
items = [{'price': -50, 'taxable': False}]
assert calculate_grand_total(items, 0) == -50.0
Trust nothing yet. Run them.
3. Run Tests on a Free Server
I used a free server with Python and pytest. No local setup needed.
pip install pytest
pytest test_legacy.py -v
All five passed. That is expected. Passing only means the AI read the code.
4. Prove the Tests Can Die
Characterization tests must fail if behavior changes. Break the code and see if the tests notice.
Manual mutation: change taxable handling to always apply tax. Or change rounding to floor. Or remove the discount.
I used mutmut on the free server:
pip install mutmut
mutmut run --paths-to-mutate legacy.py
mutmut results
Results table:
| Mutation | Survived? | Test that caught it |
|---|---|---|
Remove taxable check |
No | test_missing_taxable |
| Remove discount multiplication | No | test_discount_applies |
Replace round with int
|
No | test_taxable_item_with_rate |
Change 1 + tax_rate to tax_rate
|
No | test_taxable_item_with_rate |
| Change empty list return | Yes | None |
The last mutation survived. The AI did not write a test for empty list behavior. I added it manually:
def test_empty_items_rounds_to_zero():
assert calculate_grand_total([], 0.1) == 0.0
Now all mutations died. The oracle is real.
5. Refactor with the Smallest Safe Change
Now you can refactor. Pick one behavioral change, not ten. I simplified the loop using a generator:
def _taxed_price(item):
if item.get('taxable'):
return item['price'] * (1 + item['tax_rate'])
return item['price']
def calculate_grand_total(items, discount, currency_round=True):
subtotal = sum(_taxed_price(item) for item in items)
subtotal *= (1 - discount)
return round(subtotal, 2) if currency_round else subtotal
Run the tests again. They pass. Run mutation again. All mutations still die. Done.
Decision Matrix
Use this workflow when:
- The function has no tests
- The function has high fan-in
- The function has hidden branching or rounding
- You need a quick safety net before touching it
Skip it when:
- The function has good coverage already
- You are changing behavior on purpose (write new tests instead)
- The function is a one-liner with no branches
- You need performance work (characterization tests do not help)
Limitations and Risks
AI-generated characterization tests are only as good as your mutation audit. A test that never fails on any mutation proves nothing.
Free servers have resource limits. Large mutation runs may time out. Scope your mutations to one function, not the whole repo.
Also, characterization tests lock existing bugs. If a behavior is wrong, your test makes it permanent. Fix the bug before or after the refactor, but know the trade-off.
Who Should Not Use This
Do not use this for greenfield code. Write real tests there.
Do not use this to justify a rewrite. The workflow protects incremental refactors, not big-bang replacements.
And do not use this if you cannot verify the AI output. You must understand every assertion the AI writes.
Try It on Your Messiest Function
Take one function. Generate tests with AI. Run them on a free server. Mutate and verify. Then make your smallest safe change.
That is the entire job. Half an hour, and your risk drops to zero.
Top comments (0)