Add Tests to Code You Didn't Write
23 Mar 2026You inherited a function with no tests, unclear behavior, and a comment that says // don't touch this, breaks checkout. You need to change it. Don’t try to understand it first — pin its current behavior with a characterization test, then refactor with the safety net already in place.
Characterization tests, not unit tests
A characterization test doesn’t assert what the code should do. It asserts what the code actually does, right now, today — bugs included. The point isn’t correctness, it’s a tripwire: if your refactor changes behavior, the test fails, and you find out immediately instead of in production.
test('current behavior of calculateDiscount', () => {
// don't reason about whether this SHOULD be the output —
// just capture what it IS, by running the code and asserting the result
expect(calculateDiscount({ total: 100, coupon: 'SAVE10', tier: 'gold' }))
.toBe(85) // yes, gold tier stacks the coupon — apparently intentional?
})
Run the function, note the output, write the assertion. Repeat for every distinct input shape you can identify — different branches, edge cases, the weird tier interaction nobody remembers deciding on.
Finding the branches worth testing
Read the function for its if/switch/early-return structure, not its business logic. Each branch is a case you need at least one test for. You’re not trying to understand why gold tier stacks discounts — you’re making sure that if it does today, your refactor doesn’t silently change it.
# a coverage run on the untouched function tells you what inputs
# you've exercised and what branches you haven't hit yet
npm test -- --coverage --collectCoverageFrom='src/discount.js'
Aim for branch coverage on the specific function you’re about to touch, not the whole file. You don’t need to characterize code you’re not going to change.
Now refactor
With characterization tests green, you have permission to actually understand and improve the code. Extract functions, rename variables, simplify the conditional — run the tests after every step. Green means behavior didn’t move. Red means it did, and now you decide: was that change intentional (good, update the test and note why) or accidental (bad, you just caught a bug before it shipped). The loop:
flowchart TD
A["Write characterization tests (pin current behavior)"] --> B["Refactor the code"]
B --> C["Run the tests"]
C -->|"green"| B
C -->|"red"| D{"Was the change intentional?"}
D -->|"yes"| E["Update the test, note why"]
D -->|"no"| F["Bug caught — fix separately, with its own test"]
# tight loop: change, test, repeat
npm test -- --watch discount.test.js
The trap: don’t fix bugs during characterization
If you spot an actual bug while characterizing — the gold-tier stacking looks wrong — resist fixing it in the same pass. Characterize the current (buggy) behavior first, commit that as a safety net, then fix the bug as a separate, visible change with its own test for the correct behavior. Mixing “pin behavior” and “fix behavior” into one diff means you can’t tell, six months later, whether a regression was the refactor or the bug fix.
When there’s no way to call the function in isolation
Legacy code is often untestable because it reaches out to a database, a global, or the DOM directly. Before you can characterize it, you may need a seam: extract the side effect behind a parameter or an injected dependency, without changing behavior, and verify manually that the extraction is a no-op. This is the one place refactoring has to come before testing — you can’t pin behavior you can’t invoke.
Once you’re past that seam, the loop is the same: characterize, then refactor with the net underneath you. The tests you write are throwaway-quality by design — messy inputs, real numbers, no elegant abstractions. Their job is coverage of what’s real, not being a good example of test-writing.