Characterization tests: how to change code nobody understands
You do not need to know why a behaviour exists to protect it while you work around it. This is the technique that makes legacy modernization survivable — and the one most teams skip.
PlaceholderAuthor headshot — Principal Engineer · Software48×48 · Post byline
[PLACEHOLDER] Engineer Name
Principal Engineer · Software
7 min readSoftware
PlaceholderPost cover — characterization testing1200×675 · Post cover
The standard objection to modernizing a legacy system is reasonable: we cannot safely change this because we do not understand what it does. The standard response — read it until you do — does not scale past a few thousand lines, and it does not work at all when the behaviour depends on fourteen years of accumulated data.
Characterization tests sidestep the problem. Instead of encoding what the system should do, they record what it currently does. You do not need to understand a behaviour to protect it.
The distinction that matters
A unit test asserts intent: given this input, the answer should be that, because the specification says so. A characterization test asserts observed reality: given this input, the system currently returns that, and I want to know immediately if that changes.
The difference is that a characterization test is never wrong at the moment you write it. It cannot be — you generated the expectation from the system itself. What it gives you is a tripwire.
python
# Not "this is correct" — "this is what it does today".
@pytest.mark.parametrize("quote_id", HISTORICAL_QUOTE_IDS)
def test_pricing_unchanged(quote_id, snapshot):
result = legacy.calculate_price(load_quote(quote_id))
snapshot.assert_match(result, f"pricing/{quote_id}.json")
How to build the corpus
Production data is the only input worth using. Synthetic cases exercise the paths you thought of, which are by definition the paths you already understand.
Pull a representative sample of real historical inputs — a few hundred is usually plenty, spread across the full range of what the system has actually seen
Run them through the current system and record every output as a snapshot
Include the edge cases the operators complain about; those are where the undocumented behaviour lives
Commit the snapshots. They are the specification you never had
What you find
This is where the technique earns its keep. On a recent engagement the snapshots surfaced eleven cases where the legacy pricing logic did something indefensible — rounding in the wrong direction, applying a discount twice under a specific combination of conditions.
The instinct is to fix them. Resist it, at least at first. Downstream reports had been reconciling against those outputs for a decade. Correcting the rounding would have been correct and would have broken the month-end reconciliation in a way nobody would have connected to the pricing change.
We asked the client which of the eleven were bugs. Nobody had ever asked them that question. Two were bugs. Nine were deliberate decisions whose rationale had been lost.
That conversation is the real output. Preserve the behaviour, document it, and let the business decide — separately, and with the consequences visible — whether to change it.
Then you can move
With the net in place, extraction becomes mechanical. Move one piece of functionality behind an interface. Run the suite. If the snapshots still match, the change was behaviour-preserving. If they do not, you know precisely which inputs diverged before anything reaches production.
This is what makes incremental modernization possible where a rewrite is not. Each increment is independently verifiable and independently reversible. You can stop after three of eight and still be meaningfully better off — which matters, because budgets and priorities move, and a strategy that only pays off at completion usually does not get there.
Keep reading
Related writing
PlaceholderPost cover — retrieval and permissions1200×675 · Blog card
Everyone tests retrieval systems for made-up answers. Almost nobody tests them for correctly-sourced answers shown to the wrong person — which is the failure that ends up in a breach notification.
[PLACEHOLDER] Founder Name
PlaceholderPost cover — dependency mapping1200×675 · Blog card
Two weeks of discovery feels like two weeks of nothing happening. It is the cheapest insurance available on a migration, and the reason most overruns are decided before any workload moves.
[PLACEHOLDER] Founder Name
PlaceholderPost cover — attack path analysis1200×675 · Blog card
A list sorted by CVSS score is not a priority order, because severity is a property of a vulnerability and risk is a property of your network. Here is what to ask for instead.
[PLACEHOLDER] Founder Name
This kind of problem is what we get hired for.
If the post described your situation more precisely than you'd like, the assessment is the cheapest way to find out how bad it actually is.