A messy pricing helper is a common place where coding agents overreach, because the file looks small and the tests look green. Consider a labeled brownfield example: quoteTotal.js mixes tax flags, coupon fallbacks, and a loyalty multiplier that three checkout paths already call differently. An agent that sees sparse tests often proposes a clean rewrite and a renamed public API in one diff. That rewrite can stay green while two regional carts drift by a few cents, because nothing locked the old, slightly wrong behavior.
Characterization tests exist for that gap between a green suite and the behavior callers already shipped. They do not claim the current helper is correct, and they do not bless rounding quirks as a product strategy. They pin what the mess already does, including silent fallbacks, so the next edit has a contract.
Why a Green Suite Still Lies
Sparse unit tests usually encode the author's intention, not the production contract that three call sites already rely on. A single happy-path fixture cannot represent coupon casing, missing tax flags, or the zero-discount fallback operations already depend on. When an agent rewrites the helper, those tests still pass if the new code matches the fixture, even if every other branch moved. The recorded contract is the fixture, and the fixture is almost never the full matrix of flags.
The practical failure is not that agents generate large diffs on first contact with a messy file. The failure is that the repository never recorded the behavior the callers already shipped to production. Characterization tests reverse that order by recording first, then changing the smallest thing that still reduces risk.
A Compact Mess Worth Pinning
The module below is labeled sample code, not a dump from a production employer. It is intentionally awkward so the test strategy stays visible, including shared mutable state. Three callers already depend on fail-open coupons, default tax, and a NaN-to-zero collapse that a rewrite would “fix” without asking.
// quoteTotal.js — labeled messy example, not production source
let LOYALTY = 1.0;
function money(n) {
return Math.round(n * 100) / 100;
}
function quoteTotal(subtotal, coupon, flags) {
flags = flags || {};
let total = Number(subtotal);
if (coupon) {
const code = String(coupon).trim().toUpperCase();
if (code === "SAVE10") total = total * 0.9;
else if (code === "SAVE5") total = total - 5;
// unknown coupons silently do nothing
}
if (flags.tax === true) {
total = total * (flags.rate != null ? flags.rate : 1.08);
}
if (flags.loyalty) {
total = total * LOYALTY;
}
if (isNaN(total)) return 0;
return money(total);
}
module.exports = {
quoteTotal,
setLoyalty: (v) => {
LOYALTY = v;
},
};
Global loyalty state leaks across tests when a file forgets to reset it after a run. Unknown coupons fail open, which reorder flows already treat as a valid no-op rather than an error. NaN becomes a zero total instead of an exception, and a batch invoicing job already swallows that zero.
Step 1: Inventory Callers Before Any Generated Tests
Do not start by asking a model to improve the module, because that prompt skips the contract you already shipped. Start by listing every call site and the flags it actually passes in the tree. The inventory is the specification you have, not the specification anyone wished they had written.
- Search the repo for
quoteTotal(and record file, coupon usage, and tax flags. - Note hidden coupling, especially the
setLoyaltyexport and tests that never reset it. - Capture one production-shaped input per call site, including empty coupons and missing
flags. - Write those inputs as a table before any test file exists, and refuse patches until the table is filled.
rg -n "quoteTotal\(" -g '!node_modules' -g '!dist'
rg -n "setLoyalty\(" -g '!node_modules' -g '!dist'
A short inventory table keeps later tests honest and stops an agent from inventing a fourth flags combination.
| Call site | Coupon | flags.tax |
flags.rate |
Notes |
|---|---|---|---|---|
cart.js |
user input | true |
1.08 or 1.0
|
trims whitespace on codes |
reorder.js |
stored code | omitted | omitted | relies on fail-open unknown coupons |
invoiceJob.js |
null |
true |
1.0875 |
needs two-decimal rounding |
Step 2: Characterization Tests That Pin Quirks
A characterization test asserts the current result, even when that result is ugly or inconvenient. Name the tests after observed behavior, not after a future money type or a hoped-for API. Expected numbers below were computed from this implementation; they are a lock, not a finance sign-off.
// quoteTotal.characterization.test.js
const { quoteTotal, setLoyalty } = require("./quoteTotal");
afterEach(() => setLoyalty(1.0));
test("SAVE10 applies before default tax and rounds to cents", () => {
expect(quoteTotal(100, "save10", { tax: true })).toBe(97.2);
});
test("SAVE5 subtracts a flat amount then applies an explicit rate", () => {
expect(quoteTotal(40, "SAVE5", { tax: true, rate: 1.0875 })).toBe(38.06);
});
test("unknown coupons fail open instead of rejecting the quote", () => {
expect(quoteTotal(50, "WELCOME", { tax: true })).toBe(54);
});
test("missing flags object is treated as no tax and no loyalty", () => {
expect(quoteTotal(19.99, null)).toBe(19.99);
});
test("NaN subtotal collapses to zero rather than throwing", () => {
expect(quoteTotal("n/a", "SAVE10", { tax: true })).toBe(0);
});
test("loyalty multiplies after tax when the global is not 1", () => {
setLoyalty(0.95);
expect(quoteTotal(100, null, { tax: true, loyalty: true })).toBe(102.6);
});
Run the file before changing production code, and treat a red fixture as a measurement error until a caller proves the implementation wrong. If the test disagrees with quoteTotal.js, keep the implementation and fix the expected value. The suite is a seismograph, not a style guide.
npx --yes jest quoteTotal.characterization.test.js --runInBand
--runInBand keeps the global loyalty mutation from racing other files in the same process. Characterization suites that share process state should stay serial until that global is removed on purpose. Commit the green suite as its own change, with no production edit in the same diff.
A tiny oracle script for new rows
When the inventory table grows, do not hand-compute rounding again if the lock is “what the file does today.” Print the current result, paste it into a test name that describes the quirk, and only then decide whether the quirk is allowed to survive.
// printQuote.js — labeled helper for capturing current results
const { quoteTotal } = require("./quoteTotal");
const rows = [
[100, "save10", { tax: true }],
[40, "SAVE5", { tax: true, rate: 1.0875 }],
[50, "WELCOME", { tax: true }],
];
for (const [subtotal, coupon, flags] of rows) {
console.log(JSON.stringify({ subtotal, coupon, flags, out: quoteTotal(subtotal, coupon, flags) }));
}
node printQuote.js
Step 3: The Smallest Safe Change
After the suite is green and committed, pick one mechanical edit that a reviewer can hold in working memory. Do not rename the public function, do not introduce a money class, and do not close the unknown-coupon path in the same patch. Smallest means one observable behavior change, or one structural extract with zero behavior change.
Useful first moves, ordered by risk rather than by how impressive the diff looks:
- Reset or wrap the
LOYALTYglobal so tests cannot leak, with no quote formula change. - Extract
applyCoupon(total, coupon)without changing any branch result the suite already pinned. - Replace
flags = flags || {}with a default parameter, then re-run the same file. - Only then consider failing unknown coupons, and only behind an explicit opt-in flag that old callers do not set.
Move 2 should leave every characterization assertion identical. If any assertion flips, the extract was not behavior-preserving and the patch should stop immediately.
function applyCoupon(total, coupon) {
if (!coupon) return total;
const code = String(coupon).trim().toUpperCase();
if (code === "SAVE10") return total * 0.9;
if (code === "SAVE5") return total - 5;
return total;
}
function quoteTotal(subtotal, coupon, flags) {
flags = flags || {};
let total = applyCoupon(Number(subtotal), coupon);
if (flags.tax === true) {
total = total * (flags.rate != null ? flags.rate : 1.08);
}
if (flags.loyalty) {
total = total * LOYALTY;
}
if (isNaN(total)) return 0;
return money(total);
}
Agents fail this step when they clean up the fail-open coupon path during the extract, because the cleanup looks locally correct. Re-run the committed characterization file on that extract before any reviewer reads the new helper. A flipped assertion is cheaper than a regional cart that drifted after merge.
Decision Table: Characterize, Extract, or Stop
Print this table next to the pull request template so review comments point at a cell instead of at taste. The table is the workflow’s only scoring system: missing locks block work, mixed behavior changes reject the patch, and identical assertions accept structure-only edits.
| Signal | Action | Reason |
|---|---|---|
| Tests miss a call-site flag combination | Add a characterization case first | The lock is incomplete |
| Diff rewrites rounding and coupon policy together | Reject the patch | Two behaviors moved at once |
| Extract keeps every assertion identical | Accept | Structure changed, contract did not |
| Agent wants a new public API | Defer until callers have adapters | Renames hide contract drift |
NaN-to-zero is load-bearing for a batch job |
Keep it, and name the test after the collapse | Correctness is a product decision |
Where Free Model Access and a Free Server Fit
Generating the first characterization cases from a call-site inventory is repetitive work, and it does not require a paid evaluation cluster. MonkeyCode offers free model access and a free server option that can draft those tests and execute them in an isolated workspace.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The useful loop is narrow and should stay narrow. Paste the inventory table and the messy module, ask for characterization tests that pin current results, then run the suite on the free server before any refactor patch is proposed. Treat the model output as untrusted draft tests: every expected value must be re-executed against the real file, including rounding. Do not ask the model to fix rounding, coupons, or NaN handling until that suite is green and committed on its own.
This article does not claim specific model names, token quotas, hardware profiles, or how long a free server remains available. Those details change, and a workflow that depends on an unpublished quota is not a workflow a team can schedule. If a local Node install already runs Jest, the same inventory-to-lock loop works there; the free server is optional isolation, not a requirement for characterization tests. One isolated run of that loop is enough to see whether the sequence fits the repo.
Limitations and Who Should Skip This
Characterization tests freeze today’s behavior, including bugs that finance may already want gone next sprint. If a regulator already forbids the fail-open coupon path, pinning it without a removal ticket creates false confidence and a harder later change. Do not use this sequence on cryptographic code, authorization checks, or anything where preserving “what it does today” is itself unsafe.
The approach also wastes time on greenfield modules that already have an explicit contract and a complete unit suite. If the public behavior is already documented, write a normal regression test and make the small change without the extra ceremony. Skip it when the helper is ten lines, has one caller, and the agent diff is already smaller than the test file you would add.
A characterization suite that never lands in CI is theater, because the lock disappears as soon as the next rewrite arrives. If the team will not run Jest on that file in the default pipeline, stop at the inventory table and fix the pipeline first. Shared mutable state, like the loyalty global above, also limits parallel test runners until the extract removes it.
Repeatable Order, With Stopping Rules
The repeatable order is inventory, pin, extract, then one behavior change, and each step has a hard stop. Missing call sites block tests, a red characterization file blocks extracts, and a flipped assertion blocks the pull request. That order is slower than rewriting the module in one prompt, and it is the reason regional carts stop drifting by cents after a tidy-looking diff.
Keep the characterization file beside the messy module until each quirk is either removed on purpose or documented as product behavior in the test name. The smallest safe change is the one existing callers can survive without a silent change to money.
Top comments (0)