When you refactor code with no tests, you are flying blind.
Characterization tests give you a map.
Write them first. Change code in tiny steps. Verify after each step.
This post shows a concrete workflow.
You will see a messy discount function.
You will lock its behavior with characterization tests.
Then you will refactor it without breaking a single case.
The Messy Function
Here is a legacy function I found in a payment service.
It calculates discounts. No one knows all the rules.
def apply_discount(price, customer, code, items_count):
final = price
if customer == "vip":
final = price * 0.9
elif code == "SUMMER":
final = price * 0.8
if items_count >= 5 and customer != "basic":
final = final - 2.0
if final > 200:
final = final * 0.95
if customer == "basic" and code == "SUMMER":
final = final + 5.0
if final < 0:
final = 0
return round(final, 2)
Nobody can explain why SUMMER adds five dollars for basic customers.
It is probably a bug. But it is shipped behavior.
Step 1: Characterize Before You Change
Characterization tests record what the code does today.
They do not test what should happen.
They test what does happen.
Write one test per input dimension.
Use representative values: boundaries, nulls, unusual combinations.
import pytest
def test_apply_discount_regular():
assert apply_discount(100, "regular", "", 1) == 100
def test_apply_discount_vip():
assert apply_discount(100, "vip", "", 1) == 90
def test_apply_discount_summer():
assert apply_discount(100, "regular", "SUMMER", 1) == 80
def test_apply_discount_basic_summer_bug():
assert apply_discount(100, "basic", "SUMMER", 1) == 85 # locked bug
def test_apply_discount_items_discount():
assert apply_discount(100, "regular", "", 5) == 98
Run them. Watch them pass.
Now every future change is measurable.
Step 2: Generate Draft Tests, Then Verify
You can ask an AI model to suggest test cases.
I used MonkeyCode's free model access for this.
The suggestions were useful as a checklist.
They were not correct enough to copy blindly.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The model suggested edge cases like negative prices and zero items.
Those were good additions.
It also suggested a test for code being None, which the code would crash on.
That is a real gap, but adding that test first would break the suite.
Characterization tests should match current behavior, not ideal behavior.
So I added only the cases that passed.
def test_apply_discount_negative_price():
# Current behavior: negative price becomes 0 after checks
assert apply_discount(-50, "regular", "", 1) == 0
Use AI drafts as a starting point.
Trust your own verification over the model's confidence.
Step 3: Create a Baseline on a Free Server
A test suite must run somewhere.
You can run it locally, but team members need a shared baseline.
MonkeyCode's free server option gives you a temporary remote runner.
Push a branch. The server runs your characterization suite.
git checkout -b characterize-discount
# add tests to test_discount.py
git push origin characterize-discount
# run the suite on the free server
monkeycode ci --file test_discount.py
The output is a clear pass/fail list.
Save that log as your golden baseline.
Every refactor commit will be compared against it.
Step 4: Refactor in Smallest Steps
Do not rewrite the whole function at once.
Extract one rule at a time. Run tests after every commit.
Start with the simplest extraction: the customer discount.
def _customer_discount(price, customer):
if customer == "vip":
return price * 0.9
return price
Change apply_discount to use it.
def apply_discount(price, customer, code, items_count):
final = _customer_discount(price, customer)
if code == "SUMMER":
final = final * 0.8
if items_count >= 5 and customer != "basic":
final = final - 2.0
if final > 200:
final = final * 0.95
if customer == "basic" and code == "SUMMER":
final = final + 5.0
if final < 0:
final = 0
return round(final, 2)
Run the tests. They pass.
The behavior is identical.
Commit with a clear message.
git commit -m "Extract VIP discount rule"
Repeat for each rule.
After four extractions, the function reads clearly.
def apply_discount(price, customer, code, items_count):
final = _base_discount(price, customer)
final = _apply_code_discount(final, code)
final = _apply_volume_discount(final, customer, items_count)
final = _apply_cap_discount(final)
final = _apply_basic_summer_penalty(final, customer, code)
return _round_non_negative(final)
Step 5: Diff Before and After
A characterization suite ensures each step keeps old behavior.
But a missing test can hide a regression.
Use a differential check on random inputs.
Generate random tuples of the four arguments.
Run the old and new functions on each.
Compare outputs.
import random
def diff_check(old_func, new_func, samples=1000):
for _ in range(samples):
price = random.randint(-100, 2000)
customer = random.choice(["regular", "vip", "basic", "none"])
code = random.choice(["", "SUMMER", "WINTER", "VIP"])
items = random.randint(0, 10)
old = old_func(price, customer, code, items)
new = new_func(price, customer, code, items)
if old != new:
return price, customer, code, items, old, new
return None
Run it before merging.
Any mismatch means you missed a rule.
Limitations
Characterization tests do not verify correctness.
They verify consistency.
If the original code has a bug, the test locks the bug in.
This workflow suits risk-averse refactors.
It slows you down. That is the point.
Do not use it for greenfield code or for features you are actively changing.
Do not blindly trust AI-generated test drafts.
You are responsible for the safety net.
The Pattern
Characterize. Minify. Refactor. Verify.
That is the cadence.
Start with one messy function.
Lock its behavior with tests.
Refactor in tiny commits.
Diff before you merge.
The smallest safe change is one you can undo in seconds.
Make every commit that small.
Top comments (0)