Before you change untested legacy code, you need to know what it currently does. Not what the spec says it does, not what the original author intended — what it actually does, bugs included. Characterisation tests are how you find out: tests that assert the system’s present behaviour, whatever that behaviour turns out to be. They are the seatbelt you put on before touching anything, and like a seatbelt they look like overhead right up until the moment they are the only thing between you and the windscreen.
What a characterisation test is
A normal test asserts what the code should do. A characterisation test asserts what it does. You do not design the expected values — you discover them: call the function with a representative input, record what comes back, and pin that as the assertion. Michael Feathers named the technique, and his framing is the useful one: the tests characterise the system the way a naturalist characterises an animal, by observation rather than by intention.
The uncomfortable part is deliberate: if the rounding is wrong, the test asserts the wrong rounding. If the date maths breaks on the 31st, the test pins the breakage — with a comment saying so. You are not endorsing the bug. You are recording the truth that some downstream system, somewhere, has probably grown to depend on it. In an old system, the behaviour is the spec, whether anyone likes it or not.
Why they come before any refactor
Refactoring means changing structure without changing behaviour. On an untested system that definition is unenforceable: you cannot preserve behaviour you have not captured. Every “safe” cleanup of untested code is actually a bet — usually a bet made by the person who knows the code least, since the people who knew it have left. Characterisation tests turn the bet back into engineering. The workflow is rigid on purpose:
- Pin first — write characterisation tests around the code you intend to touch, covering the inputs production actually sends, edge cases included.
- Refactor under the pin — restructure freely; the suite screams the moment observable behaviour shifts.
- Change behaviour separately — when you do want to fix the bug, that is a distinct commit that consciously updates the pinned assertion. Structure changes and behaviour changes never travel in the same diff.
The seatbelt for the strangler pattern
This is where characterisation tests earn their keep at scale. We have written about strangling a monolith without stopping the roadmap — wrap the old system, route traffic through a facade, extract one capability at a time. Every extraction carries the same question: does the new implementation behave like the old one? A characterisation suite is the executable answer. Run it against the monolith to capture the contract; run the same suite against the new service; diff. Combined with shadow traffic, it turns “we think the new billing path matches” into a report, and it makes each migration step reversible with confidence rather than hope. Without it, every extraction is a re-implementation from folklore.
When to delete them
Characterisation tests are scaffolding, not architecture. They exist to make one dangerous period — the time between “nobody understands this code” and “this code has intentional tests” — survivable. They should not outlive it.
Delete or replace them when the pinned behaviour becomes understood and intentional: as you learn what the code should do, promote the pin into a real test with a name that states the rule, and drop the observational one. Delete them when you deliberately fix a pinned bug — a test that still asserts the old wrong rounding is now a lie with a green tick. And delete them when the strangler migration completes and the old implementation is gone; the new service’s own intentional suite is the contract now. A characterisation suite left to fossilise becomes the worst kind of test debt: hundreds of assertions that document what the system happened to do in 2019, failing loudly whenever anyone improves anything.
Boring, first, always
Modernisation work is judged by its disasters, and most of the disasters trace back to the same omission: somebody changed code whose behaviour nobody had captured. Pinning current behaviour before touching it is unglamorous, mechanical work — which also makes it a natural fit for AI-assisted generation under review, since recording observed outputs is exactly the kind of pattern-dense task tooling does well and an engineer can verify. It is the first thing we do on any legacy engagement, and the reason the rest of the engagement stays boring.
If you have a system everyone is afraid to touch, our dedicated teams start with the seatbelt, not the surgery — book a technical call, not a sales call.
