Introducing a PHPUnit Baseline for Legacy Projects Step by Step
AI generated
@test
assert
PHPUnit · Legacy Code · Strategy
A PHPUnit Baseline for Legacy Projects
Step by step from zero to a resilient test foundation

A legacy project with zero test coverage feels like a wall with no handhold. With characterization tests as a starting point, clear risk based prioritization, and realistic milestones, you can build a resilient PHPUnit baseline step by step, without overwhelming the team with an unrealistic all or nothing goal.

15 min read Legacy code Characterization tests Baseline

1. Why 'just write more tests' does not work for legacy code

The naive advice that a legacy project should simply get 'more tests' regularly fails against the reality of grown codebases. Classes with hundreds of lines, hidden static calls, global state, and deeply nested dependencies cannot be tested in isolation without significant effort. Anyone trying to write a classic unit test with clean mocks for such a class immediately right away spends hours untangling dependencies before a single assertion even exists.

The more pragmatic path follows Michael Feathers' concept of characterization tests: instead of asking what the code should do, you first capture what the code actually does. These tests act as a safety net for upcoming refactorings, not as a specification of desired behavior. Only once that net is in place can the code be restructured safely and gradually replaced with real, behavior driven specification tests.

2. Characterization tests: freezing the current state

A characterization test calls the method under investigation with a realistic input and simply records what actually comes back, regardless of whether that behavior is correct or even buggy. The test is first written with a deliberately wrong expectation, such as self::assertSame('PLACEHOLDER', $result), run once, and the actual output from the failure message is then copied into the assertion. This approach may look inelegant, but it is the fastest way to document the real behavior of an unfamiliar class.

It matters to write several characterization tests for different inputs, especially edge cases like empty arrays, null values, or unusual strings, since that is exactly where surprising behavior tends to hide, behavior a later refactoring could accidentally change. The goal is not completeness in the classic sense, but a safety net that covers the most important behavioral paths before anything in the code is touched.


final class PricingCalculatorCharacterizationTest extends TestCase
{
    public function testCalculatesDiscountForKnownInput(): void
    {
        $calculator = new PricingCalculator();

        // Value was determined by running the test once, not guessed.
        $result = $calculator->calculate(100.0, 'VIP', 3);

        self::assertSame(76.5, $result);
    }

    public function testHandlesEmptyCustomerGroup(): void
    {
        $calculator = new PricingCalculator();

        $result = $calculator->calculate(100.0, '', 1);

        // Documents real behavior even though it looks questionable.
        self::assertSame(100.0, $result);
    }
}

3. Prioritizing by risk instead of file size

Without clear prioritization, the reflex is to start with the simplest class because it is quickest to test. That produces fast wins but rarely protects the areas that are actually error prone. A more sensible approach prioritizes by a combination of change frequency and business risk: code that is touched often and becomes expensive when it breaks, such as price calculation, shipping costs, or payment processing, deserves a safety net first.

A simple but effective approach is combining the number of git commits per file over the last twelve months with a rough risk assessment from the team. Files that change frequently and are also considered risky form the first priority tier. Files that are rarely touched and carry little risk can deliberately sit at the bottom of the list, even if they would technically be the easiest to test.

4. Introducing seams to expose testable code

Many legacy classes are untestable because they instantiate their own dependencies via new, use static calls into Zend-like registries, or have constructors with side effects, such as a database call inside the constructor. Before a meaningful unit test is possible, a so called seam must be introduced at exactly those points, a minimal structural change that allows a dependency to be injected from the outside without changing the actual behavior.

The most common and safest seam is the extract and override technique: a method that creates a hard dependency, such as new PDO(...), is extracted into a protected method that is overridden in the test by an anonymous subclass. This technique changes no visible behavior and is therefore low risk to perform even without existing test coverage, before the next, bigger restructuring step follows.


class LegacyOrderExporter
{
    protected function createConnection(): PDO
    {
        return new PDO('mysql:host=legacy-db', 'user', 'pass');
    }

    public function export(int $orderId): array
    {
        $pdo = $this->createConnection();
        // ... existing logic unchanged ...
        return [];
    }
}

final class LegacyOrderExporterTest extends TestCase
{
    public function testExportReturnsExpectedShape(): void
    {
        $exporter = new class extends LegacyOrderExporter {
            protected function createConnection(): PDO
            {
                return new PDO('sqlite::memory:');
            }
        };

        self::assertIsArray($exporter->export(1));
    }
}

5. From characterization tests to real specification tests

Once a safety net of characterization tests exists, a first refactoring can begin, such as splitting a three hundred line method into smaller, named steps. After each small refactoring step, the existing tests confirm that observable behavior has not changed. Only once the code is structured clearly enough to actually articulate real domain knowledge about desired behavior are the characterization tests gradually replaced with real specification tests that describe what the code should do, not just what it happens to do.

This transition should not be forced by rewriting every characterization test at once. Instead, for each identified bug or new requirement, exactly the affected test is replaced with a deliberate specification, while the remaining characterization tests continue to act as a safety net for the rest of the class. This way the test suite organically shifts from pure behavior documentation into a real contract about desired behavior.


// Before: characterization test, only documents current state
public function testCalculatesDiscountForKnownInput(): void
{
    $result = (new PricingCalculator())->calculate(100.0, 'VIP', 3);
    self::assertSame(76.5, $result);
}

// After: real specification test following clarification with the business side
public function testVipCustomersReceiveFifteenPercentDiscountAboveThreeItems(): void
{
    $calculator = new PricingCalculator();

    $result = $calculator->calculate(100.0, 'VIP', 3);

    self::assertEqualsWithDelta(85.0, $result, 0.01,
        'VIP customers get 15% off starting at 3 items per business rule.');
}

6. Wiring a baseline into the CI pipeline

Once the first tests exist, it pays to record a coverage baseline in the CI pipeline that locks in the current state as a floor. If coverage drops below that value, the build fails. If it rises, the baseline is raised manually or automatically. This ratchet principle prevents new code from being added without tests, without simultaneously demanding that the entire legacy base be retrofitted immediately.

It matters to start the baseline realistically low, say five or ten percent, rather than setting an ambitious target like fifty percent. A target set too high demotivates the team because it feels unreachable, while a low but strictly enforced starting value makes real progress visible while also preventing coverage from slipping backward.

7. Realistic milestones instead of a vague end goal

A goal like '80 percent test coverage' without a timeframe or intermediate steps usually stays lip service. More effective is a series of concrete, dated milestones, such as 'all payment related classes have at least one characterization test by the end of Q2' or 'the three most frequently changed classes have a dependency injection seam by the end of the month'. Milestones like these are verifiable and can be folded into normal sprint planning instead of existing as a separate, usually neglected side project.

It helps to tie every milestone to a concrete artifact visible to the team, such as a dashboard showing the number of tested classes per module. Progress that is visible tends to continue, unlike progress that only lives as an internal number buried somewhere in the CI configuration.

8. Securing buy in from the team

A baseline strategy rarely fails on technical obstacles, it fails on missing team buy in when developers perceive writing tests as extra, unpaid work on top of the actual feature. It helps to establish writing at least one characterization test as a fixed part of every change to a still untested class, instead of treating test writing as a separate, often postponed task.

Equally important is making small wins visible, for instance briefly mentioning in retrospectives which particularly dreaded legacy area finally got a safety net this week. This visibility creates motivation and makes clear that the baseline strategy is actually having an effect, rather than remaining a purely theoretical goal.

9. Common pitfalls when introducing a baseline

A common mistake is treating characterization tests as permanent and never evolving them into real specification tests. That leaves the test suite as a pure regression safety net, never actually documenting the code's real business intent, which leaves new team members facing the same comprehension problems as on day one.

A second pitfall is thinking of the baseline strategy purely in terms of unit tests and skipping integration tests entirely, even though many bugs in legacy systems occur exactly at the seams between modules. A balanced strategy combines both levels: fast unit tests for isolable logic and a few targeted integration tests for the most critical interfaces between legacy systems and new code.

Phase Goal Typical artifact
Phase 1: capture Document current behavior Characterization tests for risk classes
Phase 2: expose Establish testability Seams via extract and override
Phase 3: safeguard Prevent regressions Coverage baseline in the CI pipeline
Phase 4: refine Capture business behavior Real specification tests instead of current state
Phase 5: anchor Establish culture Visible dashboard, fixed milestones

Mironsoft

Test automation, Magento quality assurance, and CI integration

Tests that catch real bugs instead of just turning green?

We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.

Test Audit

Reviewing existing suites for mocking antipatterns and blind spots.

Test Strategy

Meaningfully combining unit, integration, and MFTF tests for Magento projects.

CI Integration

Setting up fast, reliable test runs in GitLab CI or GitHub Actions.

10. Summary

Legacy Baseline: The Essentials at a Glance

Start

Characterization tests document actual, not desired, behavior.

Prioritization

Risk plus change frequency instead of file size or convenience.

Structure

Seams via extract and override make legacy classes testable.

Enforcement

Anchor a coverage baseline with a ratchet principle in the CI pipeline.

11. FAQ: Legacy Baseline: The Essentials at a Glance

1What is a characterization test?
A characterization test documents the actual, observable behavior of a piece of legacy code, regardless of whether that behavior is correct from a business perspective. It serves as a safety net for refactoring, not as a statement about how the code should behave.
2How do I write a characterization test when I do not know the behavior?
You first write a deliberately wrong assertion, run the test, and copy the actual output from the failure message into the assertion. This approach documents real behavior without requiring prior analysis.
3How do I prioritize which classes get tests first?
The most effective approach combines change frequency, measured for example via git commits, with business risk in case of a failure. Classes that change often and are expensive when they break, such as price calculation or payment processing, should be secured first.
4What is a seam in the context of legacy code?
A seam is a minimal structural change that allows a hard dependency to be replaced from the outside without changing the class's observable behavior. The extract and override technique, where a dependency is moved into an overridable protected method, is the most common seam.
5How high should the initial coverage baseline be?
Realistically low, often five to ten percent, rather than an ambitious target like fifty percent. What matters is that the baseline is strictly enforced and not allowed to drop, while it is raised gradually as real progress is made.
6When do characterization tests become real specification tests?
Once the code is structured clearly enough and the team, together with the business side, can articulate the desired behavior, the affected characterization test is replaced with a deliberate specification, usually as part of a bug fix or a new requirement touching that exact spot.
7Do I have to replace all characterization tests right away?
No, the transition should happen organically. Only the tests touched by active work on the respective class are gradually turned into real specification tests, while the rest continue to serve as a pure regression safety net.
8How do I prevent coverage from dropping again after introducing a baseline?
Through a ratchet principle in the CI pipeline: the baseline may not be undercut, a build with lower coverage than the last achieved value fails. As coverage rises, the baseline is raised accordingly.
9How do I get the team to actually support the baseline strategy?
By making writing at least one characterization test a fixed part of every change to an untested class, instead of treating it as a separate, often postponed task, and by making small progress visible, for example in retrospectives or on a dashboard.
10Is a baseline strategy worthwhile for a legacy Magento project too?
Yes, especially in grown Magento projects with many custom modules, combining characterization tests for core areas like price calculation or checkout with a gradually rising coverage baseline is a realistic path, without overwhelming the team with an unrealistic full coverage target.