Characterization Tests und sichere Refactorings
Legacy Code ist Code ohne Tests – und er lässt sich ohne Tests nicht sicher ändern. Der Ausweg aus diesem Dilemma beginnt mit Characterization Tests: Tests, die das tatsächliche Verhalten dokumentieren, nicht das gewünschte. Seams einführen, statische Abhängigkeiten aufbrechen und schrittweise Klassen extrahieren – ohne eine einzige Zeile Produktionslogik zu ändern, bevor der erste Test grün ist.
Inhaltsverzeichnis
- 1. Was ist Legacy Code wirklich?
- 2. Characterization Tests: Verhalten dokumentieren, nicht erfinden
- 3. Seams einführen: Testpunkte in Legacy-Code schaffen
- 4. Statische Abhängigkeiten aufbrechen
- 5. Sprout Method und Sprout Class: sicher Code hinzufügen
- 6. Extract Class unter laufenden Tests
- 7. Legacy-Refactoring-Techniken im Vergleich
- 8. Zusammenfassung
- 9. FAQ
1. Was ist Legacy Code wirklich?
Michael Feathers definiert Legacy Code in seinem Buch Working Effectively with Legacy Code präzise: Code ohne Tests ist Legacy Code. Das Alter des Codes, die verwendete PHP-Version oder die Codequalität spielen dabei keine primäre Rolle. Ein perfekt strukturiertes PHP-8.4-System ohne eine einzige Testklasse ist Legacy Code; ein chaotisches PHP-5.6-Projekt mit vollständiger Testsuite ist es nicht. Diese Definition verschiebt das Problem und die Lösung: Der Weg aus dem Legacy-Code-Dilemma führt durch Tests, nicht durch Rewrite.
In Magento-Projekten begegnet man Legacy Code besonders häufig in zwei Formen. Erstens als gewachsene Block-Klassen, die Datenbankzugriffe, Geschäftslogik, Template-Rendering und externe API-Aufrufe in einer einzigen toHtml()-Methode zusammenfassen. Zweitens als Observer-Klassen, die globale State-Änderungen vornehmen, auf Mage::getSingleton() zugreifen oder direkt SQL-Abfragen absetzen. Beide Formen sind charakteristisch schwer testbar, weil sie keine sauberen Grenzen haben, an denen man Abhängigkeiten austauschen kann. Die folgenden Techniken adressieren genau dieses Problem.
2. Characterization Tests: Verhalten dokumentieren, nicht erfinden
Der erste Schritt beim Testbarmachen von Legacy Code ist nicht Refactoring, sondern Dokumentation durch Tests. Ein Characterization Test beschreibt das, was der Code tatsächlich tut – auch dann, wenn dieses Verhalten falsch oder unerwünscht ist. Das ist der fundamentale Unterschied zu einem normalen Unit Test, der das gewünschte Verhalten beschreibt. Characterization Tests schützen beim Refactoring davor, versehentlich Seiteneffekte zu entfernen, von denen anderer Code abhängt.
Die Technik ist einfach: Den Code mit realen Eingaben aufrufen, die Ausgabe beobachten und in einer Assertion fixieren. Wenn der Code einen bestimmten String zurückgibt, testet man genau diesen String. Wenn er eine bestimmte Exception wirft, testet man genau diese Exception mit genau dieser Nachricht. Die Tests werden zunächst grün sein, weil sie das Ist-Verhalten abbilden. Wenn ein Refactoring anschließend einen dieser Tests rot werden lässt, wurde eine Verhaltensänderung eingeführt – bewusst oder unbewusst.
<?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. Seams einführen: Testpunkte in Legacy-Code schaffen
Ein Seam ist eine Stelle im Code, an der das Verhalten verändert werden kann, ohne den Code selbst zu ändern. Michael Feathers unterscheidet drei Arten von Seams: Object Seams (Methoden, die überschrieben werden können), Preprocessing Seams (Konstanten, Includes) und Link Seams (welche Klasse geladen wird). In PHP sind Object Seams die praktisch relevanteste Kategorie, weil PHP-Klassen durch Vererbung und Constructor-Injection austauschbare Abhängigkeiten ermöglichen.
Die einfachste Technik, einen Object Seam einzuführen, ist das Extrahieren einer Methode für den problematischen Aufruf und das Überschreiben dieser Methode in einer Testunterklasse. Eine Legacy-Klasse, die direkt new Mailer() aufruft, kann durch Extraktion von protected function createMailer(): Mailer testbar gemacht werden – ohne den Aufrufer zu ändern. Im Test erstellt man eine anonyme Unterklasse, die createMailer() überschreibt und einen Mock zurückgibt. Das ist ein legitimer Seam ohne jede Produktionsänderung.
<?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. Statische Abhängigkeiten aufbrechen
Statische Methodenaufrufe sind das schwierigste Testbarkeitsproblem in Legacy-PHP-Code. Registry::get('current_product'), Logger::getInstance()->log('...') oder Config::getValue('tax_rate') lassen sich in Unit Tests nicht ohne weiteres ersetzen. Die Lösung beginnt mit dem Wrap-Muster: Die statische Abhängigkeit wird in einer eigenen Methode oder Klasse verpackt, die per Dependency Injection ersetzt werden kann.
Konkret bedeutet das: Eine neue Klasse RegistryAdapter mit einer Instance-Methode get(string $key), die intern Registry::get($key) aufruft. Der Legacy-Code wird so geändert, dass er den RegistryAdapter per Constructor entgegennimmt – oder per Setter, wenn Constructor-Änderung nicht möglich ist. Im Test übergibt man einen Mock des Adapters. Der Adapter selbst braucht keinen eigenen Unit Test, weil er keiner Logik enthält – er ist ein reiner 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 und Sprout Class: sicher Code hinzufügen
Die Sprout Method-Technik ist die sicherste Art, neue Funktionalität in Legacy-Code einzufügen, ohne bestehende Logik zu berühren. Anstatt eine vorhandene, untestierte Methode zu ändern, extrahiert man die neue Logik in eine neue Methode – mit vollständigen Tests – und ruft diese aus der alten Methode auf. Die alte Methode bleibt unverändert; die neue Methode ist vollständig testbar und getestet. Das minimiert das Risiko, weil der Änderungsradius auf die neue Methode beschränkt ist.
Die Sprout Class-Technik geht einen Schritt weiter: Wenn die neue Logik zu umfangreich für eine einzelne Methode ist, wird sie vollständig in eine neue Klasse ausgelagert. Diese Klasse ist von Anfang an testbar und getestet, weil sie keine Legacy-Abhängigkeiten hat. Die Legacy-Klasse instanziiert die neue Klasse und delegiert an sie. Mit der Zeit wächst der testbare Teil des Systems durch neue Sprout Classes, während der Legacy-Code schrittweise ebenfalls getestet und refactored wird.
6. Extract Class unter laufenden Tests
Die mächtigste Refactoring-Technik für Legacy-Code ist Extract Class, aber sie erfordert eine bestehende Testsuite als Sicherheitsnetz. Das Vorgehen: Erst Characterization Tests schreiben, dann eine neue Klasse mit der extrahierten Verantwortlichkeit erstellen, die neue Klasse vollständig testen, dann die Legacy-Klasse auf die neue Klasse delegieren lassen und schließlich prüfen, ob die Characterization Tests noch grün sind. Die Reihenfolge ist entscheidend – jeder Schritt wird durch Tests abgesichert, bevor der nächste beginnt.
In Magento-Projekten bedeutet das typischerweise: Eine Block-Klasse, die Preisberechnung, Steueranwendung und Formatierung in einer Methode vermischt, wird in drei separate Klassen aufgeteilt. Jede übernimmt genau eine Verantwortlichkeit, hat ihren eigenen Unit Test und kommuniziert über saubere Interfaces. Die Block-Klasse selbst wird zum Koordinator ohne eigene Logik. Dieses Ziel wird Schritt für Schritt, Test für Test erreicht – nicht durch einen Big-Bang-Rewrite.
7. Legacy-Refactoring-Techniken im Vergleich
Nicht alle Refactoring-Techniken sind gleich riskant. Die Wahl der richtigen Technik hängt davon ab, wie gut der vorhandene Code bereits getestet ist und wie tief die Abhängigkeiten gehen.
| Technik | Risiko | Tests vorab nötig | Typischer Einsatz |
|---|---|---|---|
| Characterization Test | Kein | Nein — erzeugt erst Tests | Immer als erster Schritt |
| Sprout Method | Sehr gering | Nur für neue Methode | Neue Features in Legacy-Klassen |
| Object Seam (Unterklasse) | Gering | Characterization Tests | Abhängigkeiten austauschen |
| Static Wrapper | Gering | Characterization Tests | Registry, Singleton, Config |
| Extract Class | Mittel | Vollständige Characterization Tests | God Classes aufbrechen |
Die Characterization Test-Technik ist immer der erste Schritt, weil sie kein Risiko trägt: Das Produktionssystem wird nicht geändert. Sprout Method und Object Seam haben minimales Risiko, weil der Änderungsradius eng begrenzt ist. Extract Class trägt das höchste Risiko, weil Verhaltensänderungen durch die Verschiebung von Logik leicht entstehen – weshalb vollständige Characterization Tests als Sicherheitsnetz unerlässlich sind.
8. Zusammenfassung
Legacy Code testbar zu machen beginnt nicht mit Refactoring, sondern mit Characterization Tests: Tests, die das tatsächliche Verhalten des Codes fixieren, unabhängig davon, ob es korrekt ist. Erst wenn diese Sicherheitsnetz-Tests existieren, beginnt das sichere Refactoring. Seams sind die Techniken, um Testpunkte in Code zu schaffen, ohne das Verhalten zu ändern – Object Seams durch Methodenextraktion und Unterklassen, Static Wrappers für globale Abhängigkeiten. Sprout Method und Sprout Class ermöglichen es, neue Logik von Anfang an testbar zu implementieren, ohne in die bestehende Logik einzugreifen.
Der Schlüssel zu nachhaltigem Fortschritt bei Legacy-Code ist Geduld mit dem Prozess. Kein Schritt ohne Tests davor. Kein Refactoring ohne grüne Characterization Tests danach. In Magento-Projekten bedeutet das: Jede neue Feature-Anfrage ist eine Gelegenheit, den berührten Legacy-Code mit Characterization Tests zu versehen, bevor die neue Logik als Sprout Class implementiert wird. Mit der Zeit wächst die Testsuite, schrumpft der untestierte Bereich und steigt das Vertrauen in den Code.
Legacy Code testbar machen — Das Wichtigste auf einen Blick
Characterization Tests zuerst
Tatsächliches Verhalten fixieren, bevor eine einzige Zeile Produktionscode geändert wird. Das Sicherheitsnetz für jedes Refactoring.
Object Seams nutzen
Factory-Methoden extrahieren und im Test per anonymer Unterklasse überschreiben – Abhängigkeiten austauschen ohne Constructor-Änderung.
Statische Aufrufe wrappen
Registry, Singleton und statische Config in Adapter-Klassen einkapseln. Die Adapter-Klasse selbst braucht keinen Test – nur ihr Interface.
Sprout Method für neuen Code
Neue Logik immer als eigene, vollständig getestete Methode oder Klasse einführen. Nie direkt in untestierte Methoden hineinschreiben.