DEV Community

Dakota Huang
Dakota Huang

Posted on

Characterize, Minify, Refactor: A Safe Path Through a Messy Codebase

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

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

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

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

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

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

Run the tests. They pass.
The behavior is identical.
Commit with a clear message.

git commit -m "Extract VIP discount rule"
Enter fullscreen mode Exit fullscreen mode

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

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

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)