PHPUnit-Baseline fuer Legacy-Projekte schrittweise einfuehren
AI generated
@test
assert
PHPUnit · Legacy Code · Strategie
Eine PHPUnit-Baseline fuer Legacy-Projekte
Schrittweise von null auf ein belastbares Testfundament

Ein Legacy-Projekt ohne jede Testabdeckung wirkt wie eine Mauer ohne Angriffspunkt. Mit Characterization Tests als Startpunkt, einer klaren Priorisierung nach Risiko und realistischen Meilensteinen laesst sich schrittweise eine belastbare PHPUnit-Baseline aufbauen, ohne das Team mit einem unrealistischen Alles-oder-Nichts-Ziel zu ueberfordern.

15 Min. Lesezeit Legacy Code Characterization Tests Baseline

1. Warum 'einfach Tests schreiben' bei Legacy-Code nicht funktioniert

Der naive Ratschlag, ein Legacy-Projekt solle einfach 'mehr Tests' bekommen, scheitert regelmaessig an der Realitaet gewachsener Codebasen. Klassen mit hunderten Zeilen, versteckten statischen Aufrufen, globalem Zustand und tief verschachtelten Abhaengigkeiten lassen sich nicht ohne weiteres in Isolation testen. Wer versucht, fuer eine solche Klasse sofort einen klassischen Unit-Test mit sauberen Mocks zu schreiben, verbringt Stunden mit dem Entwirren von Abhaengigkeiten, bevor ueberhaupt eine erste Assertion steht.

Der pragmatischere Weg orientiert sich an Michael Feathers' Konzept der Characterization Tests: Statt zu fragen, was der Code tun sollte, wird zunaechst festgehalten, was der Code tatsaechlich tut. Diese Tests dienen als Sicherheitsnetz fuer bevorstehende Refactorings, nicht als Spezifikation des gewuenschten Verhaltens. Erst wenn dieses Netz steht, laesst sich der Code gefahrlos umbauen und schrittweise durch echte, verhaltensgetriebene Spezifikationstests ersetzen.

2. Characterization Tests: Den Ist-Zustand einfrieren

Ein Characterization Test ruft die zu untersuchende Methode mit einer realistischen Eingabe auf und protokolliert schlicht, was tatsaechlich zurueckkommt, egal ob dieses Verhalten korrekt oder sogar fehlerhaft ist. Der Test wird zunaechst mit einer bewusst falschen Erwartung geschrieben, etwa self::assertSame('PLATZHALTER', $result), einmal ausgefuehrt, und die tatsaechliche Ausgabe aus der Fehlermeldung des Testlaufs in die Assertion uebernommen. Dieses Vorgehen mag unelegant wirken, ist aber der schnellste Weg, das reale Verhalten einer unbekannten Klasse zu dokumentieren.

Wichtig ist, mehrere Characterization Tests fuer unterschiedliche Eingaben zu schreiben, insbesondere fuer Randfaelle wie leere Arrays, null-Werte oder ungewoehnliche Zeichenketten, da genau dort ueberraschendes Verhalten lauert, das ein spaeteres Refactoring versehentlich veraendern koennte. Ziel ist nicht Vollstaendigkeit im klassischen Sinn, sondern ein Sicherheitsnetz, das die wichtigsten Verhaltenspfade abdeckt, bevor am Code etwas veraendert wird.


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

        // Wert wurde durch einen ersten Testlauf ermittelt, nicht spekuliert.
        $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);

        // Dokumentiert reales Verhalten, auch wenn es fragwuerdig wirkt.
        self::assertSame(100.0, $result);
    }
}

3. Priorisierung nach Risiko statt nach Dateigroesse

Ohne klare Priorisierung entsteht schnell der Reflex, mit der einfachsten Klasse zu beginnen, weil sie sich am schnellsten testen laesst. Das erzeugt zwar schnelle Erfolgserlebnisse, schuetzt aber selten die Bereiche, die tatsaechlich fehleranfaellig sind. Sinnvoller ist eine Priorisierung nach Kombination aus Aenderungshaeufigkeit und Geschaeftsrisiko: Code, der oft angefasst wird und bei einem Fehler teuer wird, etwa Preisberechnung, Versandkosten oder Zahlungsabwicklung, verdient zuerst ein Sicherheitsnetz.

Ein einfacher, aber wirkungsvoller Ansatz ist, die Anzahl der Git-Commits pro Datei der letzten zwoelf Monate mit einer groben Risikoeinschaetzung des Teams zu kombinieren. Dateien, die haeufig geaendert werden und gleichzeitig als riskant gelten, bilden die erste Prioritaetsstufe. Dateien, die selten angefasst werden und wenig Risiko bergen, koennen bewusst am Ende der Liste stehen, selbst wenn sie technisch am einfachsten zu testen waeren.

4. Seams einziehen, um testbaren Code freizulegen

Viele Legacy-Klassen sind nicht testbar, weil sie ihre Abhaengigkeiten selbst per new instanziieren, statische Aufrufe wie Zend-aehnliche Registries nutzen, oder Konstruktoren mit Seiteneffekten versehen sind, etwa einem Datenbankaufruf im Konstruktor. Bevor ein sinnvoller Unit-Test moeglich ist, muss an genau diesen Stellen ein sogenannter Seam eingezogen werden, also eine minimale strukturelle Aenderung, die es erlaubt, eine Abhaengigkeit von aussen zu injizieren, ohne das eigentliche Verhalten zu veraendern.

Der haeufigste und sicherste Seam ist die Extract-and-Override-Technik: Eine Methode, die eine harte Abhaengigkeit erzeugt, etwa new PDO(...), wird in eine protected Methode ausgelagert, die im Test durch eine anonyme Unterklasse ueberschrieben wird. Diese Technik veraendert kein sichtbares Verhalten und ist damit selbst ohne Testabdeckung risikoarm durchfuehrbar, bevor der naechste, groessere Umbauschritt folgt.


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

    public function export(int $orderId): array
    {
        $pdo = $this->createConnection();
        // ... bestehende Logik unveraendert ...
        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. Von Characterization Tests zu echten Spezifikationstests

Sobald ein Sicherheitsnetz aus Characterization Tests steht, kann ein erstes Refactoring beginnen, etwa das Aufteilen einer 300-Zeilen-Methode in kleinere, benannte Teilschritte. Nach jedem kleinen Refactoring-Schritt bestaetigen die bestehenden Tests, dass sich das beobachtbare Verhalten nicht veraendert hat. Erst wenn der Code klar genug strukturiert ist, um tatsaechliches Fachwissen ueber das gewuenschte Verhalten zu formulieren, werden die Characterization Tests schrittweise durch echte Spezifikationstests ersetzt, die beschreiben, was der Code tun soll, nicht nur, was er zufaellig tut.

Dieser Uebergang sollte nicht erzwungen werden, indem alle Characterization Tests auf einmal umgeschrieben werden. Stattdessen wird pro identifiziertem Bug oder pro neuer Anforderung genau der betroffene Test durch eine bewusste Spezifikation ersetzt, waehrend die uebrigen Characterization Tests weiterhin als Sicherheitsnetz fuer den Rest der Klasse bestehen bleiben. So verschiebt sich die Testsuite organisch von einer reinen Verhaltens-Dokumentation zu einem echten Vertrag ueber gewuenschtes Verhalten.


// Vorher: Characterization Test, dokumentiert nur Ist-Zustand
public function testCalculatesDiscountForKnownInput(): void
{
    $result = (new PricingCalculator())->calculate(100.0, 'VIP', 3);
    self::assertSame(76.5, $result);
}

// Nachher: echter Spezifikationstest nach Klaerung mit dem Fachbereich
public function testVipCustomersReceiveFifteenPercentDiscountAboveThreeItems(): void
{
    $calculator = new PricingCalculator();

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

    self::assertEqualsWithDelta(85.0, $result, 0.01,
        'VIP-Kunden erhalten ab 3 Artikeln 15% Rabatt laut Fachanforderung.');
}

6. Eine Baseline in die CI-Pipeline einhaengen

Sobald die ersten Tests existieren, lohnt es sich, eine Coverage-Baseline in der CI-Pipeline zu hinterlegen, die den aktuellen Stand als Untergrenze festschreibt. Faellt die Abdeckung unter diesen Wert, schlaegt der Build fehl. Steigt sie, wird die Baseline manuell oder automatisiert nach oben angepasst. Dieses Ratchet-Prinzip verhindert, dass neuer Code ohne Tests hinzukommt, ohne gleichzeitig zu verlangen, dass sofort die gesamte Legacy-Basis nachgeruestet wird.

Wichtig ist, die Baseline realistisch niedrig zu starten, etwa bei fuenf oder zehn Prozent, statt sie ambitioniert bei fuenfzig Prozent anzusetzen. Ein zu hoch angesetztes Ziel demotiviert das Team, weil es unerreichbar wirkt, waehrend ein niedriger, aber strikt durchgesetzter Startwert echten Fortschritt sichtbar macht und gleichzeitig verhindert, dass die Abdeckung wieder sinkt.

7. Realistische Meilensteine statt eines vagen Endziels

Ein Ziel wie '80 Prozent Testabdeckung' ohne Zeitrahmen oder Zwischenschritte bleibt meist ein Lippenbekenntnis. Wirkungsvoller ist eine Reihe konkreter, terminierter Meilensteine, etwa 'Alle Zahlungs-relevanten Klassen haben bis Ende Q2 mindestens einen Characterization Test' oder 'Die drei am haeufigsten geaenderten Klassen haben bis Ende des Monats einen Seam fuer Dependency Injection'. Solche Meilensteine sind pruefbar und lassen sich in ein normales Sprint-Planning integrieren, statt als separates, meist vernachlaessigtes Nebenprojekt zu existieren.

Es hilft, jeden Meilenstein an ein konkretes, fuer das Team sichtbares Artefakt zu binden, etwa ein Dashboard mit der Anzahl getesteter Klassen pro Modul. Fortschritt, der sichtbar ist, wird eher fortgesetzt als Fortschritt, der nur in einer internen Zahl irgendwo in der CI-Konfiguration steckt.

8. Akzeptanz im Team sichern

Eine Baseline-Strategie scheitert selten an technischen Huerden, sondern an fehlender Akzeptanz im Team, wenn Entwickler das Schreiben von Tests als zusaetzliche, unbezahlte Arbeit neben dem eigentlichen Feature empfinden. Es hilft, das Schreiben mindestens eines Characterization Tests als festen Bestandteil jeder Aenderung an einer noch ungetesteten Klasse zu etablieren, statt Testschreiben als separate, oft aufgeschobene Aufgabe zu behandeln.

Ebenso wichtig ist, kleine Erfolge sichtbar zu machen, etwa in Retrospektiven kurz zu erwaehnen, welcher besonders gefuerchtete Legacy-Bereich diese Woche erstmals ein Sicherheitsnetz erhalten hat. Diese Sichtbarkeit erzeugt Motivation und macht deutlich, dass die Baseline-Strategie tatsaechlich Wirkung zeigt, statt ein rein theoretisches Ziel zu bleiben.

9. Haeufige Stolpersteine bei der Einfuehrung

Ein haeufiger Fehler ist, Characterization Tests fuer immer als endgueltig zu betrachten und nie zu echten Spezifikationstests weiterzuentwickeln. Damit bleibt die Testsuite ein reines Regressions-Sicherheitsnetz, ohne je die eigentliche fachliche Absicht des Codes zu dokumentieren, was neue Teammitglieder weiterhin vor die gleichen Verstaendnisprobleme stellt wie am ersten Tag.

Ein zweiter Stolperstein ist, die Baseline-Strategie nur fuer Unit-Tests zu denken und Integrationstests komplett auszusparen, obwohl gerade in Legacy-Systemen viele Fehler an den Naehten zwischen Modulen entstehen. Eine ausgewogene Strategie kombiniert beide Ebenen: schnelle Unit-Tests fuer isolierbare Logik und wenige, gezielte Integrationstests fuer die kritischsten Schnittstellen zwischen Altsystemen und neuem Code.

Phase Ziel Typisches Artefakt
Phase 1: Erfassen Ist-Verhalten dokumentieren Characterization Tests fuer Risiko-Klassen
Phase 2: Freilegen Testbarkeit herstellen Seams via Extract-and-Override
Phase 3: Absichern Regressionen verhindern Coverage-Baseline in der CI-Pipeline
Phase 4: Praezisieren Fachliches Verhalten festhalten Echte Spezifikationstests statt Ist-Zustand
Phase 5: Verankern Kultur etablieren Sichtbares Dashboard, feste Meilensteine

Mironsoft

Testautomatisierung, Magento-Qualitätssicherung und CI-Integration

Tests, die echte Fehler finden statt nur grün zu leuchten?

Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.

Test-Audit

Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.

Teststrategie

Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.

CI-Integration

Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.

10. Zusammenfassung

Legacy-Baseline: Das Wichtigste auf einen Blick

Start

Characterization Tests dokumentieren das reale, nicht das gewuenschte Verhalten.

Priorisierung

Risiko plus Aenderungshaeufigkeit statt Dateigroesse oder Bequemlichkeit.

Struktur

Seams via Extract-and-Override machen Legacy-Klassen testbar.

Durchsetzung

Coverage-Baseline mit Ratchet-Prinzip in der CI-Pipeline verankern.

11. FAQ: Legacy-Baseline: Das Wichtigste auf einen Blick

1Was ist ein Characterization Test?
Ein Characterization Test dokumentiert das tatsaechliche, beobachtbare Verhalten eines Stuecks Legacy-Code, unabhaengig davon, ob dieses Verhalten fachlich korrekt ist. Er dient als Sicherheitsnetz fuer Refactorings, nicht als Aussage darueber, wie der Code sich verhalten sollte.
2Wie schreibe ich einen Characterization Test, wenn ich das Verhalten nicht kenne?
Man schreibt zunaechst eine bewusst falsche Assertion, fuehrt den Test aus, und uebernimmt die tatsaechliche Ausgabe aus der Fehlermeldung in die Assertion. Dieses Vorgehen dokumentiert das reale Verhalten, ohne dass man es vorher analysieren muss.
3Wie priorisiere ich, welche Klassen zuerst Tests bekommen?
Am wirkungsvollsten ist eine Kombination aus Aenderungshaeufigkeit, etwa gemessen an Git-Commits, und geschaeftlichem Risiko bei einem Fehler. Klassen, die haeufig geaendert werden und bei Fehlern teuer sind, wie Preisberechnung oder Zahlungsabwicklung, sollten zuerst abgesichert werden.
4Was ist ein Seam im Kontext von Legacy Code?
Ein Seam ist eine minimale strukturelle Aenderung, die es erlaubt, eine harte Abhaengigkeit von aussen zu ersetzen, ohne das beobachtbare Verhalten der Klasse zu veraendern. Die Extract-and-Override-Technik, bei der eine Abhaengigkeit in eine ueberschreibbare protected Methode ausgelagert wird, ist der gaengigste Seam.
5Wie hoch sollte die anfaengliche Coverage-Baseline sein?
Realistisch niedrig, oft fuenf bis zehn Prozent, statt eines ambitionierten Ziels wie fuenfzig Prozent. Wichtig ist, dass die Baseline strikt durchgesetzt wird und nicht sinken darf, waehrend sie schrittweise nach oben angepasst wird, sobald echte Fortschritte gemacht wurden.
6Wann werden Characterization Tests zu echten Spezifikationstests?
Sobald der Code klar genug strukturiert ist und das Team gemeinsam mit dem Fachbereich das gewuenschte Verhalten formulieren kann, wird der betroffene Characterization Test durch eine bewusste Spezifikation ersetzt, meist im Zuge eines Bugfixes oder einer neuen Anforderung an genau dieser Stelle.
7Muss ich alle Characterization Tests sofort ersetzen?
Nein, der Uebergang sollte organisch geschehen. Nur die Tests, die durch aktive Arbeit an der jeweiligen Klasse beruehrt werden, werden schrittweise zu echten Spezifikationstests, waehrend der Rest weiterhin als reines Regressions-Sicherheitsnetz bestehen bleibt.
8Wie verhindere ich, dass die Coverage nach der Einfuehrung wieder sinkt?
Ueber ein Ratchet-Prinzip in der CI-Pipeline: Die Baseline darf nicht unterschritten werden, ein Build mit niedrigerer Abdeckung als der zuletzt erreichte Wert schlaegt fehl. Steigt die Abdeckung, wird die Baseline entsprechend angehoben.
9Wie bekomme ich das Team dazu, die Baseline-Strategie mitzutragen?
Indem das Schreiben mindestens eines Characterization Tests fester Bestandteil jeder Aenderung an einer ungetesteten Klasse wird, statt eine separate, oft aufgeschobene Aufgabe zu sein, und indem kleine Fortschritte sichtbar gemacht werden, etwa in Retrospektiven oder einem Dashboard.
10Lohnt sich eine Baseline-Strategie auch fuer ein Magento-Legacy-Projekt?
Ja, gerade in gewachsenen Magento-Projekten mit vielen individuellen Modulen ist die Kombination aus Characterization Tests fuer Kernbereiche wie Preisberechnung oder Checkout und einer schrittweise steigenden Coverage-Baseline ein realistischer Weg, ohne das Team mit einem unrealistischen Vollabdeckungsziel zu ueberfordern.