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.
Inhaltsverzeichnis
- 1. Warum 'einfach Tests schreiben' bei Legacy-Code nicht funktioniert
- 2. Characterization Tests: Den Ist-Zustand einfrieren
- 3. Priorisierung nach Risiko statt nach Dateigroesse
- 4. Seams einziehen, um testbaren Code freizulegen
- 5. Von Characterization Tests zu echten Spezifikationstests
- 6. Eine Baseline in die CI-Pipeline einhaengen
- 7. Realistische Meilensteine statt eines vagen Endziels
- 8. Akzeptanz im Team sichern
- 9. Haeufige Stolpersteine bei der Einfuehrung
- 10. Zusammenfassung
- 11. FAQ
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.