Should I test that?

Should I add tests to legacy code?

Verdict

Yes

Yes, add tests to legacy code before you change it: characterization tests that record what the code returns today, then tests for the new behaviour, all in CI; do not set a coverage target for the whole codebase.

Why

Yes, add tests to legacy code, starting with the part you are about to change. The typical case is an old module without tests that customers use and that your team changes about once a month. Blast radius is users and Change frequency is regularly. Detectability is eventually, because a changed behaviour returns a plausible value, such as a delivery date, that nobody reports. Reversibility is with-effort, since wrong saved values need a repair script, and Test cost is moderate, about an hour for a characterization test at the module's entry point, so rule R11 gives Test.

When the decision changes
WhenDecisionWhy
The legacy code calculates charges, refunds or invoice totalsTest mandatory: pin today's amounts with characterization tests before the first changeBlast radius rises to money and Reversibility to costly, because a wrong charge ends in refunds
The legacy code decides who may read a record or exports personal dataTest mandatory, including requests that the code must denyBlast radius rises to safety-or-legal and Detectability to never, because a leaked record raises no error
A legacy module that nobody plans to change has an entry point that a test can call in about an hourTest minimally: one characterization test of its main pathChange frequency falls to rarely, so a failure has fewer chances to happen
A legacy module that nobody plans to change hides the database, the clock and global state in one long method, so a test needs days of workTest it differently: alert on its error rate and on the numbers it producesChange frequency falls to rarely and Test cost rises to heavy, while Detectability stays eventually and Reversibility with-effort
The change is a rename done by the IDE's automated refactoring, in compiled code that nothing calls by reflectionDo not test the rename; the compiler checks every callerDetectability moves to immediately and Reversibility to trivial, because the build fails before merge
You move the legacy system's data into a new schema onceTest it differently: rehearse the move on a copy of production data and diff the resultChange frequency falls to once, so a test in the suite would never run again

What breaks if you don't test

Customers rely on behaviour that nobody wrote down: a rounding rule, a skipped holiday, a default for an empty field. A change that looks local alters one of these rules, the code still returns a value of the right type, and no alert fires. Customers notice days or weeks later, and every record written in between needs a repair.

What you lose if you over-test

A coverage target for the whole legacy codebase sends the team into modules nobody changes. On tangled code each such test mocks several collaborators and asserts calls, so it breaks on the first refactor and checks no requirement.

How to test

Write characterization tests before you change anything:

  1. Find the nearest entry point a test can call: a public method, a command, or an HTTP endpoint. If there is none, cut a seam, as in Martin Fowler's Legacy Seam.
  2. Record what the code returns today for inputs sampled from production, including weekends and empty fields.
  3. Check the recorded outputs by hand. Where an output is a bug, keep the test as it is and file the bug.
  4. Make the change, add tests for the new behaviour, and run all of them in CI.

When the answer changes

  • The code moves money or decides who may see a record.
  • Nobody plans to change the module, and a test would need days of work.
  • The code runs once, such as the migration off the legacy system.

Real incident + Code example

The Saturday delivery that never came

On an online store I worked on, DeliveryEstimate::forOrder() was a 400-line PHP method from 2016 with no tests. A developer added Saturday delivery for one courier and rewrote the loop that skipped weekends, which then skipped only Sundays for every courier. Friday orders with the other two couriers showed "arrives Saturday" and arrived on Monday. No error fired, and support linked the parcel tickets to the change 11 days later. After the fix we added this test, fed with 240 production orders and the dates the old code returned for them:

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class DeliveryEstimateCharacterizationTest extends TestCase
{
    // Each row: courier, order time, date the code returned before the change
    public static function recordedOrders(): array
    {
        return json_decode(file_get_contents(__DIR__ . '/recorded-estimates.json'), true);
    }

    #[DataProvider('recordedOrders')]
    public function testKeepsTheRecordedEstimate(string $courier, string $orderedAt, string $expected): void
    {
        $estimate = DeliveryEstimate::forOrder($courier, new DateTimeImmutable($orderedAt));

        $this->assertSame($expected, $estimate->format('Y-m-d'));
    }
}

Against the broken loop, the test fails on every row whose estimate crosses a weekend for a courier without Saturday delivery.

FAQ

Is it worth adding unit tests to an existing production project?

Yes, adding tests to an existing production project pays off for the code you change, starting with a characterization test before each change. Start with the modules on your current tickets, not with modules nobody changes.

Is unit testing worth the effort in a large and old codebase?

Yes, unit testing a large, old codebase is worth the effort for the modules your team changes and for code that moves money or decides access. Where the code has no seams, start with tests at an entry point such as an HTTP endpoint.

Should I refactor legacy code before adding tests?

No, add characterization tests to legacy code before you refactor it. Before the first test exists, change the code only through steps that the IDE performs automatically, such as extracting a method to create a seam.

What is a characterization test?

A characterization test records what existing code returns today for a set of inputs and fails when a change alters that output. It pins current behaviour, bugs included, so a refactor shows every output it altered.