Engineering6 min read
Adding tests to a codebase that has none
You cannot retrofit test coverage as a project. What works on a legacy system is recording what it already does, starting with the paths that carry money.
The backend had no tests. Not thin coverage, not stale tests somebody had switched off. None. It also had no CI, and releases went out by hand from a machine that had been set up years ago by someone who no longer worked there.
The instinct in that situation is to propose a testing project: a quarter of work, a coverage target, a green badge at the end. That project fails, every time, and it fails for a reason worth understanding before starting.
You cannot test what it should do, because nobody knows
A test asserts intended behaviour. On a system like this, the intended behaviour is not written down anywhere. It exists as code that has been adjusted for years in response to real situations, and a good proportion of what looks accidental is someone solving a problem that is still real.
Write tests for what you think it ought to do and you will get failures. Then you will change the code to match your assumption, and quietly break a customer whose edge case that code was handling.
So the first tests do not assert intent. They record behaviour.
// Not "the discount should be 10%". Just: this is what it does today,
// and it must keep doing it until somebody decides otherwise.
[Fact]
public void Pricing_MatchesRecordedBehaviour()
{
var result = _pricing.Calculate(OrderFrom("fixtures/order-4471.json"));
Assert.Equal(148.32m, result.Total);
Assert.Equal(12.36m, result.Tax);
Assert.Equal("SEASONAL-2019", result.AppliedRule);
}
That third assertion is the interesting one. Nobody could explain why a rule from 2019 was still applying. The test does not care. It pins the behaviour so the question can be answered later, deliberately, instead of discovered by a customer after a refactor.
These are characterisation tests, and they are the only kind that works before you understand a system. Their job is not to prove the code is right. It is to make change detectable.
Where the first ones go
Not everywhere. A codebase this size has thousands of paths and most of them do not deserve a test at all. Two questions sort them.
The top-right quadrant is where the engagement starts and it is usually small: pricing, payment, authentication, order state. Ten to twenty tests, written in the first week, covering the code that both changes often and cannot be wrong.
The top-left is different in technique. Reports and exports are hard to assert field by field and easy to compare wholesale.
# Golden master: run it once, keep the output, and fail when it moves.
dotnet run -- export --month 2026-07 > out/actual.csv
diff out/actual.csv fixtures/2026-07.expected.csv
Crude, and it has caught more real regressions on this project than any elegant unit test. When the diff is intentional, you look at it and update the fixture. When it is not, you have found the bug before the client's finance team did.
The build comes before the tests
The first useful thing we shipped was not a test. It was a build that ran anywhere.
A legacy system that only compiles on one machine has an unstated dependency on that machine, and nobody knows what it is until the machine dies. Getting the build reproducible in CI takes a few days, usually surfaces two or three surprises, and everything afterwards depends on it. There is no point writing tests that only one laptop can run.
Order that worked here:
- A build that runs from a clean checkout, in CI, on every push.
- Characterisation tests over the money paths.
- A staging environment that matches production closely enough to be believed.
- Deploys triggered by a merge rather than by a person with the right access.
Only after four does modernisation become a normal piece of work rather than an act of faith. That order is the same one described in inheriting a legacy backend, and this is the middle of it in detail.
Testing code that was not written to be tested
The standard objection: this code has no injection points, everything is static, the database is reached from inside the method. Correct, and refactoring first is exactly the change you cannot yet make safely.
The way through is to wrap rather than restructure. Put a seam at the edge of the thing you want to pin, test through it, and leave the inside alone until the test exists.
// Before: static, unreachable, untestable.
var rate = TaxService.GetRate(order.Country);
// After: same behaviour, one seam. Nothing inside TaxService changed.
var rate = _tax.GetRate(order.Country);
One interface, one constructor parameter, and the class becomes testable without touching the logic anybody is afraid of. Do that per test, not as a refactoring sweep. The sweep is the change that breaks production.
The trap
The trap is coverage percentage. It is easy to measure, so it becomes the target, and the cheapest way to raise it is to test the code that was never going to hurt anyone. Teams end up at 60 percent coverage with the payment path still unasserted.
A better question at the end of each week: which change would now be caught that would not have been caught last week? If the answer is nothing, the tests written that week were decoration.
Worth checking too that a passing test asserts the outcome rather than the operation. That failure mode is common enough that we wrote about four instances of it found on one site in a week.
What it bought
The client did not get a modern system out of this. They got something more useful first: change became provable. A developer could alter pricing logic and know within four minutes whether anything else moved.
That is the precondition for everything else. The migration to a supported runtime is now underway, service by service, and it is only possible because every step can be checked against behaviour that was recorded before anybody started moving things.
If your system has no tests and you are wondering where to start: not with a coverage target. With the build, and then with the twenty tests over the code that handles money.