The mess is not the risk. The hidden contract is the risk.
A messy function usually works. It works in ways nobody wrote down. Change one line and you risk breaking a dozen unspoken assumptions. Characterization tests freeze the current behavior first. Only then does a smallest safe change become visible.
This walkthrough uses one legacy function. It mixes rendering, tax math, discounts, and a side effect. You get a fixture generator, a snapshot runner, a decision table, and a small safe diff. You can reuse the same sequence on any legacy module. The artifact changes. The order does not.
The function nobody wanted to touch
// legacy/orderDigest.js
const email = require('../lib/email');
function orderDigest(order, customer) {
let subject = 'Order ' + order.id;
let lines = ['Thank you, ' + customer.name];
let total = 0;
for (let i = 0; i < order.items.length; i++) {
let item = order.items[i];
let price = item.price;
if (item.taxable) {
price = price * 1.08;
}
total = total + price;
lines.push(item.sku + ' x ' + item.qty + ' = ' + price.toFixed(2));
}
lines.push('Total: ' + total.toFixed(2));
if (order.discountCode) {
total = total * 0.9;
lines.push('Discounted total: ' + total.toFixed(2));
}
const body = lines.join('\n');
email.send('orders@example.test', customer.email, subject, body);
return { subject, body, total };
}
Six behaviors are tangled together. Rendering. Tax. Discounts. Floating point. An email side effect. A return shape. Reading will not separate them. Executing will.
Step 1: Build an input corpus, not a wishlist
Invented inputs test what you hope the code does. Real inputs test what it actually does. Collect candidates from three places: git history, production logs, and existing fixtures.
Then reduce to branch coverage:
- One taxable item and one non-taxable item.
- One order with
discountCodeand one without. - A price that produces repeating decimals, like
19.99 * 1.08. - An empty items array, which exposes shape bugs.
// test/fixtures/inputCases.json
[
{
"id": "taxable-no-discount",
"order": { "id": "A-1", "items": [{ "sku": "MUG", "qty": 1, "price": 19.99, "taxable": true }] },
"customer": { "name": "Ada", "email": "ada@example.test" }
},
{
"id": "discount-code",
"order": { "id": "B-2", "items": [{ "sku": "STICKER", "qty": 2, "price": 1.5, "taxable": false }], "discountCode": "SAVE10" },
"customer": { "name": "Linus", "email": "linus@example.test" }
},
{
"id": "empty-cart",
"order": { "id": "C-3", "items": [] },
"customer": { "name": "Grace", "email": "grace@example.test" }
}
]
Coverage here means branches, not lines. Four cases exercise every conditional in this function. Volume adds confidence later. Branches catch breakage now.
Step 2: Let the legacy code generate the truths
Never hand-write expected values. Hand-written values are guesses. Run the legacy code once and record every output exactly.
Two details matter. Stub the email side effect before the module loads. Otherwise the generator spams a real inbox. Then write the fixture file once and commit it. Do not regenerate it after the refactor starts.
// scripts/generateFixtures.js
const fs = require('node:fs');
const inputs = require('../test/fixtures/inputCases.json');
const sent = [];
require.cache[require.resolve('../lib/email')] = {
exports: { send: (...args) => sent.push(args) },
};
const orderDigest = require('../legacy/orderDigest');
const fixtures = inputs.map((c) => {
const before = sent.length;
const expected = orderDigest(c.order, c.customer);
return {
id: c.id,
order: c.order,
customer: c.customer,
expected,
sentEmails: sent.length - before,
};
});
fs.writeFileSync('test/fixtures/orderCases.json', JSON.stringify(fixtures, null, 2));
Expect long decimals. Values like 21.589199999999998 may appear. Do not round them. That floating point noise is part of the contract.
Commit the fixture with a descriptive message. Reviewers can diff it line by line. If the legacy output looks wrong, escalate then. Do not silently edit the fixture to match what you want.
Then the snapshot runner:
// test/orderDigest.characterization.test.js
const test = require('node:test');
const assert = require('node:assert');
const cases = require('./fixtures/orderCases.json');
const sent = [];
require.cache[require.resolve('../lib/email')] = {
exports: { send: (...args) => sent.push(args) },
};
const orderDigest = require('../legacy/orderDigest');
for (const c of cases) {
test(`locks ${c.id}`, () => {
const before = sent.length;
const got = orderDigest(c.order, c.customer);
assert.deepStrictEqual(got, c.expected);
assert.strictEqual(sent.length - before, c.sentEmails);
});
}
deepStrictEqual locks the exact shape. The second assertion locks the side-effect count. No loose matchers. Any unapproved drift fails the suite.
Step 3: Draft the skeleton with a model, verify with execution
The test skeleton is mechanical. Import. Loop. Compare. A coding model can draft that quickly, and this is a good fit for MonkeyCode's free model access. Its free server option runs the draft without standing up a paid environment.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Keep the division of labor strict. The model proposes the test loop. The legacy code writes the expected values. A model that proposes assertions is useful. A model that proposes golden values is a liability. Execution is the only source of truth.
The model's draft still needs review. Check the stub, the loop, and the assertion mode. Then let the fixture generator overwrite any guessed values.
Step 4: Make the smallest safe change
Run the suite first. It must be green. Now choose the smallest transformation with zero observable drift. The goal is a diff you can explain in one sentence. If the explanation needs three clauses, the change is too big. Split it.
Extract the tax math into a pure function. Extract the discount math into another. Same operators. Same evaluation order. No formatting changes.
// legacy/orderDigest.js
function applyTax(price, taxable) {
return taxable ? price * 1.08 : price;
}
function applyDiscount(total, code) {
return code ? total * 0.9 : total;
}
Replace the two inline blocks:
for (let i = 0; i < order.items.length; i++) {
let item = order.items[i];
- let price = item.price;
- if (item.taxable) {
- price = price * 1.08;
- }
+ let price = applyTax(item.price, item.taxable);
total = total + price;
lines.push(item.sku + ' x ' + item.qty + ' = ' + price.toFixed(2));
}
if (order.discountCode) {
- total = total * 0.9;
+ total = applyDiscount(total, order.discountCode);
lines.push('Discounted total: ' + total.toFixed(2));
}
Run the suite again. Green means the observable behavior is identical. The diff is small enough to review in one pass. The commit message writes itself: "Extract tax and discount math. No behavior change."
The decision table for "small and safe"
| Transformation | Safe now? | Proof |
|---|---|---|
| Extract pure arithmetic helpers | Yes | Same operators, same order |
| Rename local variables | Yes | No runtime change |
| Hoist string construction | Yes | Same string output |
| Change rounding precision | No | Output changes |
Reorder lines.push calls |
No | Output order changes |
Move email.send earlier |
No | Side-effect timing changes |
Characterization tests do not tell you a change is good. They tell you a change is invisible. Those are different properties. Keep them separate.
Limitations
Snapshot tests freeze bugs too. If the legacy total is wrong for a currency, the test locks in the wrong total. A characterization test is a contract, not an endorsement.
Nondeterministic output breaks this approach. Timestamps, random IDs, or hash ordering require a seam. Inject a clock or a seed first.
The tax constant is also a business question. 1.08 may be wrong for your jurisdiction. Tests cannot answer that. Ask a domain owner.
This workflow assumes a stable code path. Concurrency or shared global state can make golden values flaky. Run the suite twice before trusting the green.
Who should not use this workflow
- Greenfield code: write intent tests instead of snapshots.
- One-line changes: the ceremony outweighs the risk.
- Security fixes: do not preserve vulnerable behavior.
- Pure functions you already understand: normal tests fit better.
The snapshot workflow is for the gray zone. Nobody knows why the code works. Everybody is afraid to touch it. Freeze it first. Then make the smallest change. Future reviewers get proof, not vibes.
Top comments (0)