Changing legacy code feels like defusing a bomb. One line moved, and a hidden side-effect explodes. The fix isn't courage. It's characterization tests.
A characterization test locks current behavior. It doesn't judge whether the behavior is correct. It just records what the code actually does. Once you have that lock, the smallest safe change becomes a routine edit.
This workflow uses free tooling. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model tier to draft test skeletons, and its free server option as a disposable test runner. Both are optional. The core method works with any editor and any local runner.
Step 1: Pick one function, not a whole service
Resist the urge to refactor an entire module. Choose a single function with visible inputs and outputs. Prefer one with no database calls and no randomness. That keeps your characterization test deterministic.
Here's a legacy endpoint I often use as a teaching example:
// app.js — legacy Express route, do not "fix" yet
app.get('/discount', (req, res) => {
const price = Number(req.query.price) || 0;
const vip = req.query.vip === 'true';
let discount = 0;
if (price > 100) {
discount = 0.1;
}
if (vip) {
discount += 0.05;
}
res.json({ final: price * (1 - discount), discount });
});
It looks simple. It has an edge-case bug waiting for you. price = -50 still gets a 10% discount. That's the behavior you must lock before changing anything.
Step 2: Draft the characterization test with a free model
Writing a test from scratch is tedious. A free language model can draft a skeleton fast. But you are the reviewer. The model does not know your repo's conventions.
I pasted the route above into MonkeyCode's free model tier with this prompt:
Write a Node.js test using node:test and assert for this Express route. Capture current behavior for prices 0, 50, 150, and vip true/false. Do not fix anything.
It generated something close to this:
// test/discount.test.js
const test = require('node:test');
const assert = require('node:assert');
const app = require('../app');
function get(path) {
return app.handle({ method: 'GET', url: path, headers: {} }, {});
}
test('price 150, not vip', () => {
const res = get('/discount?price=150');
assert.deepStrictEqual(res.body, { final: 135, discount: 0.1 });
});
That draft is wrong in two ways. It expects a real HTTP server, not a handler mock. And it ignores the price=0 case. Do not copy it verbatim.
Step 3: Human-verify every assertion before running
This is the critical step. A characterization test that encodes your assumption is just a unit test with extra steps. You need the actual current output.
Run the server manually first:
node app.js &
curl 'http://localhost:3000/discount?price=150'
# -> {"final":135,"discount":0.1}
curl 'http://localhost:3000/discount?price=0'
# -> {"final":0,"discount":0}
curl 'http://localhost:3000/discount?price=-50'
# -> {"final":-45,"discount":0.1} <-- surprising but current
Now rewrite the model's draft with the observations you just made:
// test/discount.test.js
const test = require('node:test');
const assert = require('node:assert');
const request = require('supertest');
const app = require('../app');
test('locks current discount behavior', async () => {
const cases = [
['/discount?price=0', { final: 0, discount: 0 }],
['/discount?price=50', { final: 50, discount: 0 }],
['/discount?price=150', { final: 135, discount: 0.1 }],
['/discount?price=-50', { final: -45, discount: 0.1 }],
['/discount?price=150&vip=true', { final: 127.5, discount: 0.15 }],
];
for (const [url, expected] of cases) {
const res = await request(app).get(url);
assert.deepStrictEqual(res.body, expected);
}
});
Run it. It must pass. If it fails, your expectation is wrong. Fix the test to match reality, not the other way around.
Step 4: Use the free server as a throwaway runner
You do not need a persistent CI pipeline for one repo. MonkeyCode's free server option gives you a disposable environment. Push a branch, run the test suite there, and destroy the machine.
The workflow is boring by design:
git checkout -b characterization/discount
# add test file
npm test
When the suite passes, you have a green lock. The refactor can start.
Step 5: Make the smallest safe change and re-run
Now change one line. Fix the negative-price edge case, for example:
if (price > 100 && price > 0) {
discount = 0.1;
}
Re-run the characterization test. One case fails: -50 now returns { final: -50, discount: 0 } instead of { final: -45, discount: 0.1 }.
That failure is a gift. It tells you exactly which behavior you intentionally changed. Update the test to reflect the new desired behavior:
['/discount?price=-50', { final: -50, discount: 0 }],
Run again. Green. Commit with a message that names both the change and the test update.
When this workflow is the wrong choice
Do not use characterization tests for greenfield code. They lock bugs as much as features. And if the legacy function has external side effects — writes to a database, sends emails, mutates shared state — you need dependency injection first. Characterization tests shine only when behavior is observable through pure inputs and outputs.
Also: never trust the AI-generated test without running it. I saw the model skip negative prices and mis-write the response mock. The value of the free model is a starting point, not a verdict.
The realistic payoff
A one-line refactor with characterization coverage takes about thirty minutes. The test suite stays after the fix. It becomes regression armor for the next engineer who touches that route.
You do not need a powerful machine or a paid CI plan. A free model to draft, a free server to run, and a human who verifies — that's the whole stack. The smallest safe change is the one you can measure before and after.
That's the entire discipline. Lock. Verify. Change. Re-lock.
Top comments (0)