You have a component that has been in production for nine years. It works. Nobody knows exactly how.
The person who wrote it left in 2021. The documentation describes a version from before two major changes. There are tests, but they cover about eleven per cent of the code and they were written to make the build green rather than to describe behaviour.
You have been asked to replace it.
The naive approach is to read the requirements, implement them, and ship. This fails, and it fails in a specific and predictable way: the requirements describe what the system was supposed to do in 2017, and the system has spent nine years accumulating corrections to that specification, one production incident at a time. None of those corrections are written down. They exist only as behaviour.
Characterisation testing is how you capture them.
The inversion
A normal test asserts what the code should do. A characterisation test asserts what the code currently does — including the parts that are wrong.
That last clause is the whole point and it is the thing people resist. If the legacy system returns 0.00 instead of null for a missing discount, you write a test asserting 0.00. If it silently truncates names at 40 characters, you assert the truncation. If it rounds half-down where the spec says half-up, you assert half-down.
Why capture behaviour you know is incorrect? Because something downstream depends on it. Nine years is long enough for every quirk to have grown a consumer — a report, an integration, a manual process where somebody learned to compensate. The bug is now load-bearing.
You still get to fix it. But you fix it deliberately, as a decision with a migration plan, rather than discovering it when a downstream team's month-end reconciliation breaks.
Building the suite
The workflow:
1. Write a test asserting current behaviour
2. Run it against the LEGACY system
→ passes: you understood correctly
→ fails: your model was wrong, fix the test
3. Repeat until coverage is meaningful
4. Run the suite against the NEW implementation
→ every failure is a behaviour worth a decision
Step 2 is the part that gets skipped and it is non-negotiable. A characterisation test that has never been validated against the legacy system is just an assumption with a test framework wrapped around it. Running it against the original is what converts your belief into evidence.
The output of step 4 is the genuinely valuable artefact: a list of every place the new implementation diverges. Each item gets an explicit decision — preserve, or change with a documented migration. Not a bug list. A decision list.
Where AI changed this specifically
This is high-volume, mechanical, judgement-light work, which is exactly the profile that got dramatically cheaper.
Generating a broad characterisation suite from an existing codebase used to be a months-long slog that nobody would fund. Models now read unfamiliar code well, enumerate branches, and propose test cases covering paths a human would take weeks to find — including the ones buried three conditionals deep that nobody remembers exist.
Two caveats that matter in practice.
Generated tests must still run against the legacy system before you trust them. A model's belief about what the code does is a hypothesis. Step 2 exists precisely because hypotheses are sometimes wrong, and a confidently generated wrong assertion is more dangerous than no test at all.
Prioritise review on the surprising ones. When a generated test asserts something that looks incorrect and it passes against production, you have found exactly the kind of undocumented behaviour this exercise exists to surface. Those cases deserve human attention. The boring ones do not.
The effect on programme shape is real: discovery phases that ran six months now run six weeks, and the bottleneck moves from reading code to deciding what to do about what you found.
Traffic replay for what tests miss
Characterisation tests cover what you thought to write. Production traffic replay covers what you did not.
Capture real requests and responses from the live system over a representative window, then replay the requests against the new implementation and diff the responses. Anything that differs is either a bug in the new code or an undocumented behaviour you had not enumerated.
Practical notes from doing this:
- Capture a long enough window. Month-end, quarter-end and annual paths are where the strangest logic lives, and a week of traffic will miss all of it.
- Normalise before diffing. Timestamps, generated identifiers and non-deterministic ordering will produce thousands of false differences and drown the real ones.
- Scrub sensitive fields at capture time, not later. Replay corpora have a way of ending up in places you did not plan.
- Replay read paths first. Write paths need either a sandboxed environment or shadow-mode execution with side effects suppressed, which is more work and worth doing separately.
The combination is what gives confidence. Tests give you a fast feedback loop during development; replay gives you coverage of the long tail nobody enumerated.
When to stop
You do not need total coverage, and pursuing it is how this becomes its own stalled project.
A reasonable stopping condition: every code path that appears in production traffic during a full business cycle is covered, plus every branch touching money, identity, or anything with a regulatory consequence. Paths that have not executed in a year are candidates for deletion rather than replication — and discovering that is itself a return on the exercise.
The full modernization framework — seven paths, strangler fig seams, data migration and sequencing — is here: Legacy Application Modernization. We also help teams scope replacement work when the component is business-critical.
Frequently Asked Questions
What is a characterisation test?
A test that asserts what code currently does rather than what it should do — including behaviour that is technically incorrect. It captures the accumulated corrections that exist only as behaviour in a long-running system, so a replacement can be verified against reality rather than against a stale specification.
Why assert behaviour I know is a bug?
Because after enough years in production, quirks acquire consumers — reports, integrations, manual workarounds. The bug is load-bearing. Capturing it lets you change it deliberately with a migration plan instead of discovering it when a downstream reconciliation breaks.
Do I need to run the tests against the old system first?
Yes, always. An unvalidated characterisation test is an assumption wearing a test framework. Running against the legacy system is what turns your belief about the behaviour into evidence, and it is the step most commonly skipped.
Can AI generate these tests?
It generates good first drafts — enumerating branches and proposing cases for paths a human would take weeks to find. Every generated test still has to pass against the legacy system before you trust it, and review effort should concentrate on assertions that look wrong but pass, since those are the undocumented behaviours you are hunting.
How does traffic replay differ from characterisation testing?
Tests cover what you thought to write; replay covers what you did not. Capture real production requests, replay them against the new implementation, and diff the responses. Use a window long enough to include month-end and quarter-end paths, and normalise timestamps and generated IDs before diffing.
How much coverage is enough?
Every path exercised by production traffic across a full business cycle, plus every branch touching money, identity or regulatory outcomes. Paths that have not executed in a year are candidates for deletion rather than reimplementation — finding those is part of the return.


Top comments (0)