Home/ Engineering Insights/ Adding Tests to Legacy PHP Code

Adding Tests to Legacy PHP Code Without Rewriting It First

Michael Feathers, in Working Effectively with Legacy Code, defines legacy code in a way that has stuck: it's code without tests. By that measure, plenty of young applications are legacy the day they ship. The definition is useful because it names the real problem. You can't change code safely if you can't tell when you've broken it.

The usual advice, "just write tests", doesn't help when the code is a tangle of global variables, includes with side effects and queries scattered through HTML. This post is about how to get a safety net under that kind of code without rewriting it first.

Forget coverage targets

Aiming for 80% coverage of an untested legacy application is a way to burn a year and learn nothing. The goal is narrower: put tests around the code you're about to change, and around the behaviour the business can't afford to lose. Everything else can wait until you need it.

Start from the outside

Unit tests need code that can be isolated, and legacy code often can't be. So begin at the top, with tests that behave like a user:

  • Log in, and confirm you land where you should.
  • Add an item to the cart, check out, and confirm the order record and the email.
  • Run the search and confirm that known records appear.
  • Save an item in the admin and confirm it shows up on the public page.

You can write these as HTTP tests with PHPUnit and an HTTP client such as Guzzle, pointed at a staging copy. A browser tool like Playwright is better when the pages depend on JavaScript. They're slower than unit tests and less precise about what failed, but they cover a lot of code per test, and they work without touching the code at all. That's the point.

Characterisation tests: record what it does today

Once you work inside a function, use a characterisation test. The idea is to pin down the current behaviour, whether it's right or not. You call the code, look at what comes out, and write a test that expects exactly that.

Suppose there's an old invoice function nobody trusts:

function invoice_total(array $items, string $country) {
    // ...forty lines of discounts, tax, and rounding...
}

You don't start by reading all forty lines. You run it with a handful of realistic inputs and record the results:

public function testTotalForTwoItemsInGermany(): void
{
    $items = [
        ['sku' => 'A100', 'qty' => 2, 'price' => 19.99],
        ['sku' => 'B200', 'qty' => 1, 'price' => 5.50],
    ];

    // Value copied from what the function returns today
    // (45.48 net plus 19% German VAT).
    $this->assertSame(54.12, invoice_total($items, 'DE'));
}

Name these tests honestly: they describe what the code does, not what it should do. If one of the recorded values is plainly wrong, don't fix it in the same change. Leave a note, keep the test, and decide separately whether to change the behaviour. That separation is what keeps refactoring safe.

Making code testable without rewriting it

The common obstacles are always the same, and each has a small fix:

  • Direct use of $_POST, $_SESSION or time() inside the logic. Pull the value out as a function argument at the call site, and pass it in. The logic becomes a plain function.
  • Objects created with new deep inside a method. Take the object as a parameter, defaulting to the old behaviour, so a test can substitute its own.
  • Static calls and global helpers. Wrap them in a thin method of your own. Tests can then override that method.
  • Huge functions. Don't split them up yet. Add the new behaviour as a separate small function (Feathers calls this a "sprout"), call it from the old one, and test the new function on its own.

These changes are small, mechanical and low risk, and you can do them after the outside-in tests are in place.

The database problem

Legacy code talks to the database everywhere, so tests need a database. Use a separate test database restored from a snapshot with sensible, anonymised data. Wrap each test in a transaction and roll it back at the end, so tests don't leave anything behind or depend on each other's order. Don't test against production, and double-check the connection settings, because it happens.

Global state and side effects

Old applications often run code just by including a file, and rely on variables defined elsewhere. That makes tests interfere with each other. A temporary answer is to run those tests in separate PHP processes (PHPUnit supports this per test). It's slow, and you should treat it as a marker for code that needs attention later, but it gets you moving.

Testing pages by comparing output

For template-heavy pages, a snapshot is often the quickest check. Render the page with fixed input, store the HTML, and fail the test if the output changes. Remove the parts that legitimately vary between runs, like CSRF tokens, timestamps and generated IDs, or the test will fail constantly. Snapshots are blunt, so they don't tell you whether a change is good, only that something changed. For templates, that's precisely what you want.

Use static analysis as a free safety net

Tools like PHPStan and Psalm find a surprising number of real bugs in old code without a single test: calls to undefined methods, wrong argument types, variables that might not be set. Start at the lowest level, record the existing errors in a baseline file, and make the build fail when a change adds new ones. That stops the situation getting worse while you improve it.

Run it on every change

Tests that run only when somebody remembers aren't a safety net. Put them in a continuous integration job that runs on every push, and keep it quick enough that people don't avoid it. Split slow browser tests from fast ones if they need to run at different times.

Be realistic about the effort

This takes time, and the first tests are the slowest, because you're also building the test database, the fixtures and the habits. The payoff comes later, when a change that used to need a full day of manual checking takes ten minutes. If the code is business-critical, it's one of the best investments you can make in it.

Common questions

Do I need to refactor before I can write tests?

No. Outside-in tests need no code changes. Use the small fixes above only where a specific test needs them.

Should I use PHPUnit or Pest?

Either works. Pest is built on PHPUnit, so pick whichever your team prefers.

What if the code can't run outside production?

Then the first job is a staging environment, and that's covered in the modernisation roadmap. Without it, you can't do safe work on anything else.

How do tests help with a PHP upgrade?

They tell you what changed between versions without waiting for a user to find out. Our PHP upgrade guide covers the checks to run alongside them.

Need a safety net under code nobody trusts?

We'll find the behaviour that matters, cover it with tests, and make changes you can verify.

See our legacy system support