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.
Table of Contents
- 1. Why 'just write more tests' does not work for legacy code
- 2. Characterization tests: freezing the current state
- 3. Prioritizing by risk instead of file size
- 4. Introducing seams to expose testable code
- 5. From characterization tests to real specification tests
- 6. Wiring a baseline into the CI pipeline
- 7. Realistic milestones instead of a vague end goal
- 8. Securing buy in from the team
- 9. Common pitfalls when introducing a baseline
- 10. Summary
- 11. FAQ
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.