Characterization Tests and Safe Refactoring
Legacy code is code without tests, and it cannot be changed safely without tests. The way out of this dilemma begins with characterization tests: tests that document the actual behavior, not the desired one. Introduce seams, break static dependencies and extract classes step by step, without changing a single line of production logic before the first test is green.
Table of Contents
- 1. What is legacy code really?
- 2. Characterization tests: document behavior, don't invent it
- 3. Introducing seams: creating test points in legacy code
- 4. Breaking static dependencies
- 5. Sprout Method and Sprout Class: adding code safely
- 6. Extract Class under running tests
- 7. Legacy refactoring techniques compared
- 8. Summary
- 9. FAQ
1. What is legacy code really?
Michael Feathers defines legacy code precisely in his book Working Effectively with Legacy Code: code without tests is legacy code. The age of the code, the PHP version in use or the code quality do not play a primary role. A perfectly structured PHP 8.4 system without a single test class is legacy code; a chaotic PHP 5.6 project with a full test suite is not. This definition shifts both the problem and the solution: the way out of the legacy code dilemma leads through tests, not through a rewrite.
In Magento projects, legacy code is encountered particularly often in two forms. First, as grown block classes that combine database access, business logic, template rendering and external API calls into a single toHtml() method. Second, as observer classes that make global state changes, access Mage::getSingleton() or issue SQL queries directly. Both forms are characteristically hard to test because they have no clean boundaries at which dependencies can be swapped out. The following techniques address exactly this problem.
2. Characterization tests: document behavior, don't invent it
The first step in making legacy code testable is not refactoring but documentation through tests. A characterization test describes what the code actually does, even when that behavior is wrong or undesirable. This is the fundamental difference from a normal unit test, which describes the desired behavior. Characterization tests protect against accidentally removing side effects during refactoring that other code depends on.
The technique is simple: call the code with real inputs, observe the output and fix it in an assertion. If the code returns a particular string, you test exactly that string. If it throws a particular exception, you test exactly that exception with exactly that message. The tests will initially be green because they reflect the current behavior. If a subsequent refactoring turns one of these tests red, a behavior change was introduced, whether intentionally or not.
<?php
// tests/Unit/Legacy/OrderTotalCalculatorTest.php
declare(strict_types=1);
namespace Tests\Unit\Legacy;
use PHPUnit\Framework\TestCase;
use Legacy\OrderTotalCalculator;
/**
* Characterization tests: document actual behavior, not desired behavior.
* Do NOT change these tests during refactoring. They are the safety net.
*/
class OrderTotalCalculatorTest extends TestCase
{
private OrderTotalCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new OrderTotalCalculator();
}
/**
* Documents the ACTUAL rounding behavior (may be a bug, but must not change during refactoring).
*/
public function testCalculatesTotalWithActualRounding(): void
{
// Actual observed output, even if the rounding logic seems wrong
$result = $this->calculator->calculate([
['price' => 1.005, 'qty' => 3],
['price' => 2.50, 'qty' => 1],
]);
// Characterization: this is what it currently returns, not what it "should" return
$this->assertSame('5.52', $result);
}
public function testAppliesGermanVatRate(): void
{
$result = $this->calculator->calculateWithVat(100.00);
// Characterization: hardcoded 19% in legacy code
$this->assertSame('119.00', $result);
}
public function testReturnsZeroStringForEmptyItems(): void
{
// Legacy code returns string '0' not float 0.0, document that
$this->assertSame('0', $this->calculator->calculate([]));
}
}
3. Introducing seams: creating test points in legacy code
A seam is a place in the code where behavior can be changed without changing the code itself. Michael Feathers distinguishes three kinds of seams: object seams (methods that can be overridden), preprocessing seams (constants, includes) and link seams (which class gets loaded). In PHP, object seams are the practically most relevant category, because PHP classes allow swappable dependencies through inheritance and constructor injection.
The simplest technique for introducing an object seam is extracting a method for the problematic call and overriding that method in a test subclass. A legacy class that calls new Mailer() directly can be made testable by extracting protected function createMailer(): Mailer, without changing the caller. In the test, you create an anonymous subclass that overrides createMailer() and returns a mock. That is a legitimate seam with zero production changes.
<?php
// tests/Unit/Legacy/InvoiceServiceTest.php
declare(strict_types=1);
namespace Tests\Unit\Legacy;
use PHPUnit\Framework\TestCase;
use Legacy\InvoiceService;
use Legacy\Mailer;
class InvoiceServiceTest extends TestCase
{
public function testSendsEmailAfterInvoiceCreation(): void
{
$mailerMock = $this->createMock(Mailer::class);
$mailerMock->expects($this->once())
->method('send')
->with(
$this->stringContains('@'),
$this->stringContains('Rechnung'),
$this->anything()
);
// Override the factory method via anonymous subclass, this is the Object Seam
$service = new class($mailerMock) extends InvoiceService {
public function __construct(private readonly Mailer $injectedMailer) {}
protected function createMailer(): Mailer
{
return $this->injectedMailer;
}
};
$service->createInvoice([
'customer_email' => 'kunde@example.com',
'items' => [['sku' => 'PROD-001', 'qty' => 2]],
]);
}
public function testDoesNotSendEmailWhenCustomerHasNoEmail(): void
{
$mailerMock = $this->createMock(Mailer::class);
$mailerMock->expects($this->never())->method('send');
$service = new class($mailerMock) extends InvoiceService {
public function __construct(private readonly Mailer $injectedMailer) {}
protected function createMailer(): Mailer { return $this->injectedMailer; }
};
$service->createInvoice(['customer_email' => '', 'items' => []]);
}
}
4. Breaking static dependencies
Static method calls are the hardest testability problem in legacy PHP code. Registry::get('current_product'), Logger::getInstance()->log('...') or Config::getValue('tax_rate') cannot easily be replaced in unit tests. The solution starts with the wrap pattern: the static dependency is wrapped in its own method or class, which can then be swapped out via dependency injection.
Concretely, this means: a new class RegistryAdapter with an instance method get(string $key), which internally calls Registry::get($key). The legacy code is changed so it accepts the RegistryAdapter via the constructor, or via a setter if changing the constructor is not possible. In the test, you pass in a mock of the adapter. The adapter itself needs no unit test of its own, since it contains no logic; it is a pure wrapper.
<?php
// src/Legacy/Adapter/RegistryAdapter.php
declare(strict_types=1);
namespace Legacy\Adapter;
use Magento\Framework\Registry;
/**
* Wraps static Registry access for testability.
* No logic here, pure delegation, intentionally simple.
*/
final class RegistryAdapter
{
public function __construct(private readonly Registry $registry) {}
public function get(string $key): mixed
{
return $this->registry->registry($key);
}
}
// tests/Unit/Legacy/ProductPriceBlockTest.php
declare(strict_types=1);
namespace Tests\Unit\Legacy;
use PHPUnit\Framework\TestCase;
use Legacy\Adapter\RegistryAdapter;
use Legacy\Block\ProductPriceBlock;
class ProductPriceBlockTest extends TestCase
{
public function testFormatsPriceForCurrentProduct(): void
{
$registryMock = $this->createMock(RegistryAdapter::class);
$registryMock->method('get')
->with('current_product')
->willReturn((object)['price' => 49.99, 'currency' => 'EUR']);
$block = new ProductPriceBlock($registryMock);
$output = $block->getFormattedPrice();
$this->assertSame('49,99 €', $output);
}
public function testReturnsEmptyStringWhenNoProductInRegistry(): void
{
$registryMock = $this->createMock(RegistryAdapter::class);
$registryMock->method('get')->willReturn(null);
$block = new ProductPriceBlock($registryMock);
$this->assertSame('', $block->getFormattedPrice());
}
}
5. Sprout Method and Sprout Class: adding code safely
The Sprout Method technique is the safest way to add new functionality to legacy code without touching existing logic. Instead of changing an existing, untested method, you extract the new logic into a new method, complete with tests, and call it from the old method. The old method remains unchanged; the new method is fully testable and tested. This minimizes risk because the blast radius of the change is confined to the new method.
The Sprout Class technique goes one step further: when the new logic is too extensive for a single method, it is moved entirely into a new class. This class is testable and tested from the very start, because it has no legacy dependencies. The legacy class instantiates the new class and delegates to it. Over time, the testable portion of the system grows through new sprout classes, while the legacy code is gradually tested and refactored as well.
6. Extract Class under running tests
The most powerful refactoring technique for legacy code is Extract Class, but it requires an existing test suite as a safety net. The procedure: first write characterization tests, then create a new class with the extracted responsibility, fully test the new class, then have the legacy class delegate to the new class, and finally check whether the characterization tests are still green. The order matters; every step is secured by tests before the next one begins.
In Magento projects, this typically means: a block class that mixes price calculation, tax application and formatting into one method is split into three separate classes. Each takes on exactly one responsibility, has its own unit test and communicates through clean interfaces. The block class itself becomes a coordinator with no logic of its own. This goal is reached step by step, test by test, not through a big-bang rewrite.
7. Legacy refactoring techniques compared
Not all refactoring techniques carry the same risk. Choosing the right technique depends on how well the existing code is already tested and how deep the dependencies run.
| Technique | Risk | Tests needed upfront | Typical use |
|---|---|---|---|
| Characterization Test | None | No, it creates the tests first | Always as the first step |
| Sprout Method | Very low | Only for the new method | New features in legacy classes |
| Object Seam (subclass) | Low | Characterization tests | Swapping out dependencies |
| Static Wrapper | Low | Characterization tests | Registry, Singleton, Config |
| Extract Class | Medium | Full characterization tests | Breaking up God classes |
The characterization test technique always comes first because it carries no risk: the production system is not changed. Sprout Method and Object Seam carry minimal risk because the blast radius is tightly constrained. Extract Class carries the highest risk, because behavior changes can easily creep in as logic is moved, which is why full characterization tests as a safety net are essential.
8. Summary
Making legacy code testable does not begin with refactoring but with characterization tests: tests that fix the actual behavior of the code, regardless of whether it is correct. Only once these safety-net tests exist does safe refactoring begin. Seams are the techniques for creating test points in code without changing behavior: object seams through method extraction and subclasses, static wrappers for global dependencies. Sprout Method and Sprout Class make it possible to implement new logic testable from the very start, without touching existing logic.
The key to sustainable progress on legacy code is patience with the process. No step without tests beforehand. No refactoring without green characterization tests afterward. In Magento projects, this means: every new feature request is an opportunity to cover the touched legacy code with characterization tests before the new logic is implemented as a sprout class. Over time, the test suite grows, the untested area shrinks and confidence in the code increases.
Making legacy code testable, the essentials at a glance
Characterization tests first
Fix actual behavior before a single line of production code is changed. The safety net for every refactoring.
Use object seams
Extract factory methods and override them in the test via an anonymous subclass, swapping dependencies without changing the constructor.
Wrap static calls
Encapsulate Registry, Singleton and static Config in adapter classes. The adapter class itself needs no test, only its interface does.
Sprout Method for new code
Always introduce new logic as its own, fully tested method or class. Never write it directly into untested methods.