You don't refactor a messy repo by reading it. You refactor it by locking behavior first. Characterization tests capture what the code does today. Only then can you change it safely.
I have written refactor safety articles before. This one adds a missing step: using a free model to find blind spots. The method works for any legacy function you are afraid to touch.
The Scenario
Your legacy codebase has a function named computeTotal. It handles cart pricing, discounts, and shipping. Nobody wrote tests for it. The function changed hands five times. Your task: make it readable without changing outputs.
Here is a simplified version of the mess:
function computeTotal(items, user, promo) {
let total = 0;
for (const it of items) total += it.price * it.qty;
if (user.new) total = total * 0.9;
if (promo === 'SAVE5') total = total - 5;
if (total > 100 && items.length > 5) total = total - 10;
return { total, shipping: total > 50 ? 0 : 9.99 };
}
The real version is longer. It has global state and dead branches. You want to extract logic. You cannot because you fear breaking a hidden rule.
Step 1 — Write Characterization Tests First
A characterization test records current behavior. It does not judge whether that behavior is correct. It freezes the system so refactoring won't introduce silent changes.
Start with a test file that calls the function with a few cases. Run it once with empty assertions, then copy the actual output into expected.
import test from 'node:test';
import assert from 'node:assert/strict';
import { computeTotal } from '../src/pricing.js';
const cases = [
{
name: 'empty cart',
input: [[], { new: false, country: 'US' }, null],
expected: { total: 0, shipping: 9.99 }
},
{
name: 'single item, existing user',
input: [[{ price: 10, qty: 1 }], { new: false }, null],
expected: { total: 10, shipping: 9.99 }
},
{
name: 'new user gets discount',
input: [[{ price: 100, qty: 1 }], { new: true }, null],
expected: { total: 90, shipping: 0 }
},
{
name: 'promo code SAVE5',
input: [[{ price: 20, qty: 1 }], { new: false }, 'SAVE5'],
expected: { total: 15, shipping: 9.99 }
}
];
for (const c of cases) {
test(`characterize: ${c.name}`, () => {
assert.deepEqual(computeTotal(...c.input), c.expected);
});
}
If you already know the output, write it directly. If not, use a temporary log script to print the structure.
Step 2 — Use a Free Model to Find Blind Spots
Hard cases hide in boundaries. Empty carts, promo codes, price thresholds, and floating-point arithmetic are classic traps. A human usually misses two or three. A model can suggest more.
I used MonkeyCode's free model access to generate edge-case ideas. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The model proposed these inputs:
- Negative quantity (e.g.,
qty: -1) - Promo code applied twice (
SAVE5and another code) - New user plus promo code together
- Total exactly 100 with exactly 5 items
- Floating-point prices like
19.99 * 3 -
itemscontaining an object withoutqty
Some proposals are invalid for your domain. That is fine. Filter them. Add the useful ones to your test matrix.
// additional cases after model feedback
{
name: 'floating point total',
input: [[{ price: 19.99, qty: 3 }], { new: false }, null],
expected: { total: 59.97, shipping: 9.99 }
// note: floating point may produce 59.96999999999999
},
{
name: 'total > 100 and items > 5',
input: [Array(6).fill({ price: 25, qty: 1 }), { new: false }, null],
expected: { total: 150, shipping: 0 }
}
Be careful with floating-point assertions. Use assert.deepEqual only when numbers are exact. Otherwise compare rounded values or use a tolerance helper.
Step 3 — Run and Record the Baseline
Run the test suite now. Your assertions may fail because expected values are guesses. That is normal. Replace each expected with the actual captured output.
To capture outputs quickly, write a temporary script:
const cases = [
[[], { new: false }, null],
[[{ price: 10, qty: 1 }], { new: false }, null],
[[{ price: 100, qty: 1 }], { new: true }, null]
];
for (const args of cases) {
console.log(JSON.stringify(computeTotal(...args)));
}
Copy each printed object into your test file. Now every case passes. You have a lock on the current behavior.
Run with:
node --test tests/characterize.test.js
You should see a green suite. If anything fails, your environment differs or the function has nondeterministic behavior. Investigate before going further.
Step 4 — Make the Smallest Safe Change
Now the lock is in place. Pick one behavior-preserving transformation. Do not rewrite the whole function. Extract one branch at a time.
Example: extract the new-user discount.
Before:
if (user.new) total = total * 0.9;
After:
total = applyNewUserDiscount(total, user);
function applyNewUserDiscount(total, user) {
return user.new ? total * 0.9 : total;
}
Run the test suite again. All green. Then extract the shipping rule, then the promo rule.
Each extraction is a tiny commit. Each commit keeps the suite green. If something breaks, git bisect points to a two-line diff.
Step 5 — Run the Matrix on a Free Server (Optional)
Local node_modules can hide dependency issues. A disposable server gives you a clean environment. You can also run a larger test matrix without touching your machine.
I used MonkeyCode's free server option to run the same suite on a fresh system. I copied the project into the free server, ran node --test, and got the same green result. That confirms the tests are not coupled to my local setup.
This step is optional. It adds confidence when the legacy code depends on environment-specific globals.
Limitations
Characterization tests do not fix bugs. They freeze them in place. If the current output is wrong, your test will lock in the bug. You need an explicit decision to change behavior separately.
AI-generated edge cases are suggestions. They are not domain truth. A model cannot know your business rules. Always review each proposed input against the actual contract.
Who should not use this? Solo developers rewriting a small script probably do not need the ceremony. Teams shipping financial or safety-critical logic must pair characterization with human review. Never rely on a snapshot as your only safety net.
Conclusion
Characterization-first refactoring turns a messy function into a safe edit. Lock behavior with tests. Expand input coverage with a model. Execute the smallest change. Your repo becomes editable again.
Not sure where to start? Find the module with the most implicit rules. Write one characterization test for it today.
Top comments (0)