A messy function becomes dangerous the day someone edits it. Not because the code is ugly. Because no test locks its current behavior. The fix is not a rewrite. It is a characterization suite plus one extraction. Both are small. Both are provable. This post shows the exact loop.
The Messy Function
Pick the function with the most callers and zero tests. That is your highest-risk seam. Here is the example I worked through. It is small on purpose. Real legacy code is longer, but the shape is identical.
// legacy-order.js — three routes call this. No test file exists.
const STATE_TAX = { CA: 0.0825, NY: 0.04, TX: 0.0625, '': 0 };
export function orderTotal(order, state, discountCode) {
let total = 0;
for (const item of order.items) {
let price = item.price;
if (item.qty > 10) {
price = price * 0.9;
}
if (item.category === 'bulk') {
price = price - price * 0.05;
}
total += price * item.qty;
}
if (state === 'CA' || state === 'ca') {
total = total + total * STATE_TAX[state.toUpperCase()];
} else if (STATE_TAX[state]) {
total = total + total * STATE_TAX[state];
}
if (discountCode === 'SAVE10' && total > 100) {
total = total - 10;
}
if (discountCode === 'SAVE10' && total <= 100) {
total = total * 0.95;
}
return Math.round(total * 100) / 100;
}
Count the quirks before you touch anything. ca pays tax, Tx does not. SAVE10 behaves differently above and below $100. Tax applies before discount. None of these are wrong for this exercise. They are the behavior you must lock.
Step 1: Count the Callers
Run one command first.
grep -rn 'orderTotal' --include='*.js' .
Three call sites. Three behaviors to protect. If the function had one caller, a rewrite might be cheaper. It has three. Characterization is the cheaper bet.
Step 2: Let a Free Model Draft the Scaffold
Paste the function into a free model. Ask for characterization test cases, not a cleanup. The model sees the code and proposes inputs plus expected values. MonkeyCode's free model access gave me a draft in seconds. Their free server option gave me a place to run the suite. No local setup required.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The draft is a starting point, nothing more. It saved me the blank-file stare. It did not save me the verification.
Step 3: Verify Every Expectation by Hand
Walk every proposed case against the code. The easy ones pass quickly. The quirks need patience. Here is the scaffold after my verification pass. All expectations come from reading the current code, line by line.
// orderTotal.characterization.test.js
import { expect, test } from 'vitest';
import { orderTotal } from './legacy-order.js';
const cases = [
['one normal item', { items: [{ price: 10, qty: 1, category: 'normal' }] }, '', '', 10],
['11 items get 10% off', { items: [{ price: 10, qty: 11, category: 'normal' }] }, '', '', 99],
['bulk gets extra 5%', { items: [{ price: 100, qty: 1, category: 'bulk' }] }, '', '', 95],
['state CA', { items: [{ price: 100, qty: 1, category: 'normal' }] }, 'CA', '', 108.25],
['state ca works too', { items: [{ price: 100, qty: 1, category: 'normal' }] }, 'ca', '', 108.25],
['state Tx is ignored', { items: [{ price: 100, qty: 1, category: 'normal' }] }, 'Tx', '', 100],
['SAVE10 above 100', { items: [{ price: 60, qty: 2, category: 'normal' }] }, '', 'SAVE10', 110],
['SAVE10 at 100 or below', { items: [{ price: 50, qty: 1, category: 'normal' }] }, '', 'SAVE10', 47.5],
['CA tax before SAVE10', { items: [{ price: 60, qty: 2, category: 'normal' }] }, 'CA', 'SAVE10', 119.9],
['empty basket', { items: [] }, '', '', 0],
];
for (const [name, order, state, code, expected] of cases) {
test(name, () => {
expect(orderTotal(order, state, code)).toBe(expected);
});
}
Note the ninth case. Tax applies first, then the discount. That ordering is a decision someone made. The characterization suite records it. It does not judge it.
Step 4: Run the Suite Until It Locks
npx vitest run orderTotal.characterization.test.js
Ten cases, ten passes. If any case fails, the expectation was wrong. Re-read the code, not the test. Fix the expectation only when the code is unambiguous. A green suite is your safety net. Every later edit now has to prove itself.
Step 5: Choose the Smallest Safe Change
Now you may edit. The smallest safe change moves code without changing behavior. The tax block is the best seam today. It mixes a lookup, a case-insensitivity quirk, and two branches. Extract it into one pure function.
function orderTotal(order, state, discountCode) {
let total = 0;
// ... item loop unchanged ...
- if (state === 'CA' || state === 'ca') {
- total = total + total * STATE_TAX[state.toUpperCase()];
- } else if (STATE_TAX[state]) {
- total = total + total * STATE_TAX[state];
- }
+ total = total + computeTax(total, state);
// ... discount blocks unchanged ...
return Math.round(total * 100) / 100;
}
+
+function computeTax(total, state) {
+ if (state === 'CA' || state === 'ca') {
+ return total * STATE_TAX[state.toUpperCase()];
+ }
+ return STATE_TAX[state] ? total * STATE_TAX[state] : 0;
+}
One production line changed. The extracted body is the old logic, moved verbatim. The ca/Tx asymmetry stays intact. That is the point.
Step 6: Extract, Run, Commit
Re-run the suite.
npx vitest run
Ten passes again. Commit the extraction. Do not fix the state-key quirk in the same commit. Normalizing keys is a deliberate behavior change. It deserves a new test and a new commit.
Where This Loop Breaks
This loop is not universal. Characterization only proves the inputs you thought of. It does not prove the code correct. Functions with network, filesystem, or randomness make the suite flaky. Extract pure helpers first, then characterize those. A free model can draft a scaffold fast, but it fabricates expectations too. Trusting the draft is testing the model, not the function.
Skip this loop when the design is wrong, not just the code. A rewrite needs contract tests, not characterization. Use this loop for functions that work but hurt to edit.
The Metric That Matters
| Metric | Before | After |
|---|---|---|
| Functions | 1 | 2 |
| Call sites | 3 | 3 |
| Tests | 0 | 10 |
| Changed production lines | — | 1 |
| Computed values changed | — | 0 |
That last row is the whole argument. The refactor moved code. The suite proved nothing else moved. Your messiest function is still uncharacterized. Run this loop on a copy before the next feature touches it.
Top comments (0)