All articles
12 September 2026
10 min read

Testing legacy code when there are no tests

By Tim Rutte, Cloud & Software ArchitectTopicLegacy & Modernization

A taut white safety net on blue ropes, an empty trapeze above it.

The request always sounds similar. A system has been running for twelve years, earns money every day, and nobody dares change anything in it any more. There are no tests. The question is: should we write tests first, or just start?

The answer is both and neither. Write tests, yes. But not the tests everybody is thinking of, and not for the whole system. A very particular kind of test, in a very particular place, and then carry straight on.

This article describes how that works: how to pin down behaviour nobody can explain any more, without understanding it first. The pattern is called characterization tests, and it turns the usual order around.

Why tests come before the first change

The usual reason for tests is quality: they are meant to find bugs. In a legacy system that is not the reason. The reason is a baseline.

When you change something in a system nobody understands any more, you have two options afterwards. Either you hope nothing broke, or you know. The difference between the two is a set of tests that was green before the change.

That leads somewhere uncomfortable, and it is where opinions split: these tests pin down current behaviour, even where it is wrong. If the discount calculation grants one per cent on a basket worth exactly zero euros, then the test records exactly that. Not because it is correct, but because somewhere a customer, a report or another system has been built on that behaviour, and you will only find out when you change it.

A test documenting that is not a bad test. It is a record. And the moment somebody decides the behaviour is wrong, the record becomes a deliberately changed test with one line of reasoning above it. That is the point at which a legacy system starts becoming explainable again.

What you pin down: behaviour, not intent

The usual mistake on the first attempt is to read what the code is supposed to do and test that. That is intent. Intent is written down nowhere, it gets invented while reading, and a test checking it fails without anything being broken.

What gets pinned down instead is what actually comes out. The procedure is mechanical and needs no understanding of the code:

  1. Call the function with an input that occurs in production.
  2. Write down a wrong expectation, for example assertSame('WILL_FAIL', $result).
  3. Run the test. It fails and names the actual value.
  4. Put the actual value in. Now the test is green and describes the system.

That feels wrong, because it turns the test pyramid upside down. It is right anyway, and the reason is above: you are not writing a test to check a requirement. You are writing it to make a change possible.

The golden master: many cases at once

For a single function the route above is enough. For an invoice run, an export or a price calculation with thirty special cases it is not. Every case would be its own test, and nobody writes thirty of those by hand.

The golden master solves it differently: one run produces a complete output, the output is stored, and every later run is compared against it. If one character changes, the test fails and shows exactly which.

In PHP, in its simplest form, that looks like this:

public function testInvoiceRunUnchanged(): void
{
    $output = (new InvoiceRun())->forMonth('2026-06')->asText();

    $reference = __DIR__ . '/reference/invoice-run-2026-06.txt';

    // The first run creates the reference. After that it only compares.
    if (!file_exists($reference)) {
        file_put_contents($reference, $output);
        self::markTestIncomplete('Reference created, please review and commit.');
    }

    self::assertSame(file_get_contents($reference), $output);
}

Two things about that matter more than they look.

The reference file belongs in the repository, and it gets read the first time, not just created. A reference nobody has looked at pins down every existing bug without anybody having had the chance to notice it. Fifteen minutes of reading are well spent here.

The test deliberately fails on its first run with markTestIncomplete. Otherwise the reference appears silently, the test is green, and nobody notices that nothing was checked.

Where the output is not text but an object tree, JSON makes the better reference: sorted, with readable line breaks, so that the difference in the failure report is one line and not one very long string.

Where you start, and where you do not

The obvious answer would be: where the code is worst. That is the wrong answer, because it allocates effort by annoyance instead of by value.

The right answer is in the version history. Test first what changes most often, because that is where the tests will be needed most often:

git log --since="18 months ago" --name-only --pretty=format: \
  | grep '\.php$' | sort | uniq -c | sort -rn | head -20

The second list is the one from the work that is coming. If the pricing model changes in four weeks, the tests belong around the price calculation, even if nobody has touched it in three years. You put a safety net where somebody is going to jump.

And then there is code you should not test. A module that has not changed in eighteen months, that nobody wants to go near and that works, does not need tests. It needs a note saying it is untested. The difference between the two is weeks.

Tests are not the only net. What a static analyser finds in legacy code without executing a line is covered in Introducing PHPStan into legacy code step by step.

Finding seams without rebuilding the code

This is where it gets uncomfortable in practice. The function you want to test calls a database on line three, a web service on line twelve and date() on line twenty. It cannot be tested like that, and you are not allowed to rebuild it, because rebuilding is exactly what you are currently creating protection for.

The way out is a seam: a place where behaviour can be swapped without changing the place itself. Three of them work almost always, and all three are deliberately small.

The subclass in the test. The offending method gets overridden in the test class. For that it has to be protected rather than private, and that is the only change to production code I allow at this point. It is small, it is reversible, and it changes no behaviour.

final class PriceCalculatorWithoutDatabase extends PriceCalculator
{
    protected function loadTiers(int $customerId): array
    {
        // Hard-wired: what would come from the database in production.
        return [10 => 0.05, 50 => 0.10, 100 => 0.15];
    }
}

Time as a parameter. date() and time() in the middle of the code make every test depend on the calendar. One extra parameter with a default value changes not a single caller and makes the test reproducible: public function dueOn(Order $o, ?DateTimeImmutable $now = null).

The test one level up. If a function cannot be isolated, do not test it, test the call that contains it: the HTTP endpoint, the console command, the cron job. Such tests are slower and less precise, but they need no change at all to production code, and they catch exactly the failures this is about.

Anybody who starts injecting dependencies properly and introducing interfaces at this point has misread the article. That is the work after the net, not before it. Swapping the order is the same mistake as a rewrite, only smaller.

Time, randomness and the database

Three things turn pinned-down behaviour into a test that is green sometimes and red other times. Such a test is worse than none, because after three weeks it gets ignored.

Timestamps in the output. Invoice date, creation time, a duration in milliseconds. Those get replaced before the comparison, not removed from the output: the test should keep checking that a date is there, just not which one.

$comparable = preg_replace(
    '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/',
    '<TIMESTAMP>',
    $output
);

Generated identifiers. Auto-increment numbers, UUIDs, session keys. Same treatment, with one difference: if the same identifier appears several times in the output and the relationship matters, replace it with a numbered placeholder rather than making them all identical.

Order without ORDER BY. The classic. The query returns the same order today as yesterday, because the table is small and the optimizer does not decide otherwise. It returns a different one as soon as the execution plan changes. Sort in the test before comparing, and write the place down: a query without ORDER BY whose order matters somewhere is a bug waiting for its day.

For the database itself: the test needs a known state, not a fresh one. A production extract, anonymized and cut down to a few records, is a better basis than hand-written test data, because it contains the cases nobody would invent. What matters is that every run resets it, otherwise the second run depends on the first.

Two days, one example

So that this does not stay theoretical, here is the typical sequence as it looks in practice when a pricing module is due to change.

Morning one. Evaluate the version history, identify the three files that make up the module. Establish that the calculation is reachable through a console command. That is the level at which testing happens, without touching a line of production code.

Afternoon one. Pull forty real inputs from last week's logs, run the command with them, store the outputs as reference files. Read the three most conspicuous ones. That is where the first bug turns up, one that has been in production for years and that nobody ever tripped over.

Morning two. Replace timestamps and identifiers until three consecutive runs are green. That is the part that always takes longer than planned.

Afternoon two. The actual change. Now the test shows on every run which of the forty outputs has changed. Six were expected. There are nine. Those three extra ones are the reason the net went up first.

Two days is not an estimate for your system. It is the order of magnitude: days, not weeks, and the return arrives immediately.

This work is dull and voluminous, which is exactly why it suits an agent: A coding agent in a legacy codebase.

When you stop

There is no target figure. Test coverage is a number measuring which lines were executed, not which were checked. Eighty per cent in the wrong places is worth less than twenty in the right ones.

You stop when three statements are true. The change that is coming is surrounded by tests that were green beforehand. A run is short enough that somebody starts it before committing, so under two minutes. And anybody on the team can start that run with one command, without setting anything up first.

Everything beyond that is no longer a safety net but a project of its own, and in a legacy system there is almost never a budget for that. What you do instead: every time somebody changes something, the net around that place gets added. After a year, coverage is high exactly where the work happens and low exactly where nothing does. That is not a compromise, that is the goal.

And if you have only just taken the system over and do not yet know where the work happens: how an orderly handover works is covered on its own page.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.