DEV Community

Dakota Huang
Dakota Huang

Posted on

Lock Down a Legacy Function in 30 Minutes: AI Tests, Mutation Checks, and a Free Server

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)