The biggest refactor failures come from big steps. A 200-line function turns into a rewrite. The rewrite ships with a regression.
The fix is to make the smallest safe change. Lock behavior first, then move one logical block at a time.
This post shows that workflow on a real messy function. You'll see how characterization tests and mutation testing verify every step. You'll also learn where a free model and a free server fit in without becoming dependencies.
The Messy Function
Consider a typical pricing function in a JavaScript storefront:
function calculateTotal(order) {
let total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
if (order.coupon) {
if (order.coupon.type === 'percent') {
total = total * (1 - order.coupon.value / 100);
} else {
total = total - order.coupon.value;
}
}
if (total < 0) total = 0;
return Math.round(total * 100) / 100;
}
It mixes three jobs: line totals, coupon math, and rounding. A refactor should separate them.
But first, we need to know what it does today. We don't trust the spec. We trust observed behavior.
Step 1: Lock Behavior with Characterization Tests
A characterization test records current output for representative inputs. It doesn't say what the function should do. It says what it does.
Start by writing a few inputs that cover the branches: no coupon, percent coupon, fixed coupon, negative total.
const cases = [
{
order: { items: [{ price: 10, qty: 1 }] },
expected: 10
},
{
order: {
items: [{ price: 20, qty: 2 }],
coupon: { type: 'percent', value: 10 }
},
expected: 36
},
{
order: {
items: [{ price: 5, qty: 3 }],
coupon: { type: 'fixed', value: 4 }
},
expected: 11
},
{
order: { items: [{ price: 1, qty: 1 }], coupon: { type: 'fixed', value: 10 } },
expected: 0
}
];
Run the function once to see actual values. If they match your guesses, good. If not, the tests capture the real behavior, bugs and all.
Now turn expected into hard-coded assertions. This pins the current contract.
test.each(cases)("total stays $expected", ({ order, expected }) => {
expect(calculateTotal(order)).toBe(expected);
});
At this point, you can refactor without fear. The tests will shout if the output changes.
Step 2: Prove the Tests Can Actually Fail
Pinning is not enough. Your tests might miss a branch. Mutation testing finds blind spots.
How it works: the tool makes a small change to the source—turns - into +, or deletes a line. If the test still passes, the test didn't notice the behavior change.
Run it locally for a single function:
npx stryker run
A weak test will show a high mutation score. You add the missing cases until the score is acceptable.
Mutation runs can be slow. Your laptop burns CPU while mutants run. That's where a separate runner helps.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers a free model access tier and a free server option for running jobs like this. You can draft initial characterization tests with the model, then push mutation runs to the server. The workflow, however, works just fine without those tools—they are acceleration, not a requirement.
With that in mind, here's a sample command to send the mutation run to a remote runner:
monkeycode server run -- npx stryker run
Keep your local session responsive. The server reports the same JSON output you'd get locally.
Step 3: Make the Smallest Safe Change
Now the interesting part. Do not rewrite the whole function in one commit.
Pick one block. Extract the coupon logic into its own function. Move a single concept.
function applyCoupon(total, coupon) {
if (!coupon) return total;
if (coupon.type === 'percent') {
return total * (1 - coupon.value / 100);
}
return Math.max(total - coupon.value, 0);
}
Then update calculateTotal to use it:
function calculateTotal(order) {
let total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
total = applyCoupon(total, order.coupon);
return Math.round(total * 100) / 100;
}
Wait. Did that change behavior? The original function clamps negatives only after the coupon. The extracted applyCoupon clamps inside itself. If the coupon logic leaves the total positive, results match. But if the total was negative after percent discount, the original would clamp later. Here, Math.max clamps immediately. That changes a corner case.
Run the characterization tests. They may pass because your test cases didn't include a negative after percent. Add one. Now the failure is visible.
This is the power of small steps. You can see the exact behavioral drift. You can decide whether to keep it or adjust the extraction to match the original:
function applyCoupon(total, coupon) {
if (!coupon) return total;
if (coupon.type === 'percent') {
return total * (1 - coupon.value / 100);
}
return total - coupon.value;
}
Then keep the clamp outside:
function calculateTotal(order) {
let total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
total = applyCoupon(total, order.coupon);
if (total < 0) total = 0;
return Math.round(total * 100) / 100;
}
Now the extracted function is behavior-preserving. Commit. The commit is meaningful: coupon math moved, nothing changed.
Step 4: Repeat for the Next Block
Next, extract the rounding logic. Move line-total calculation into its own function. Then extract tax if it exists.
Each extraction is a separate commit. Each commit runs the full test suite plus a focused mutation run.
Your commit history becomes a safety log:
feat: extract coupon math, no behavior change
feat: extract rounding, no behavior change
feat: extract line totals, no behavior change
Reviewers see tiny, easy-to-audit diffs. Rollback is trivial if something slips.
When Not to Use This Approach
This workflow shines for functions with unclear requirements and high risk. It hurts when you have a clear spec and you're actively fixing bugs.
If you want to change behavior, characterization tests are the wrong oracle. They lock old bugs into place. In that case, write expectation tests first.
Also skip this if the function is tiny and you already have strong unit tests. You don't need a full mutation rig for a one-line getter.
The Takeaway
Large refactors are a gamble. Each big diff multiplies your chance of introducing a subtle change.
Lock behavior with characterization tests. Verify those tests with mutation testing. Then move exactly one block at a time.
The tools stay behind the method. A free model can draft the initial test inputs. A free server can run the slow mutation jobs. But the discipline—small, verified steps—is what prevents the regression.
If you're about to refactor a messy function today, start with the smallest safe change. Your future reviewer will thank you.
This article was originally published by DEV Community and written by Dakota Huang.
Read original article on DEV Community