@test
assert
PHPUnit · Legacy Code · Characterization Tests · Refactoring
Legacy Code testbar machen:
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.

18 Min. Lesezeit Characterization Tests · Seams · Sprout Method · Extract Class PHP 8.4 · PHPUnit 11 · Legacy PHP

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.

9. FAQ: Legacy Code testbar machen mit PHPUnit

1Was ist ein Characterization Test?
Dokumentiert das tatsächliche, nicht das gewünschte Verhalten. Fixiert den aktuellen Output und schützt beim Refactoring vor versehentlichen Verhaltensänderungen.
2Was ist ein Seam?
Eine Stelle, an der das Verhalten ohne Codeänderung ausgetauscht werden kann. In PHP meist Object Seams: überschreibbare Methoden für testbare Abhängigkeiten.
3Statische Methoden für Tests aufbrechen?
Adapter-Wrapper erstellen, der den statischen Aufruf als Instance-Methode kapselt. Per Constructor-Injection einbinden. Im Test mocken.
4Sprout Method vs. Sprout Class?
Method: neue testbare Methode in der bestehenden Klasse. Class: neue eigenständige Klasse für größere Logikblöcke. Beide vermeiden Änderungen an bestehendem Code.
5Legacy Code komplett neu schreiben?
Selten sinnvoll. Rewrites unterschätzen implizite Logik. Characterization Tests + schrittweises Refactoring sind risikoärmer und bewahren akkumuliertes Wissen.
6Darf ich Characterization Tests anpassen?
Nur bewusst, wenn das Verhalten absichtlich geändert wird. Wer sie routinemäßig anpasst, verliert das Sicherheitsnetz für das Refactoring.
7Legacy Code mit direktem DB-Zugriff testen?
Repository-Pattern einführen: DB-Zugriff extrahieren, Interface definieren, im Test mocken. Übergangsweise Integrationstests mit echter Test-DB.
8Welche Code-Coverage ist realistisch?
0% am Anfang ist normal. Ziel: Coverage der geschäftskritischen Pfade. In Magento: Preisberechnung, Rabattlogik, Checkout zuerst.
9Characterization Tests als Integrationstests verhindern?
Erst Seams einführen, dann testen. Object Seams und Static Wrapper trennen Logik von Infrastruktur – Unit Tests für Logik, Integrationstests für Schichtgrenzen.
10Spezielles Tool für Characterization Tests?
Nein. PHPUnit ist ausreichend. Der Unterschied liegt im Intent des Tests, nicht im Werkzeug – normale PHPUnit-Assertions mit tatsächlichen statt gewünschten Werten.