Sie haben eine Anwendung geerbt, die seit Jahren produktiv läuft. Rechnungen werden korrekt erstellt, die Buchhaltung stimmt, die Kunden sind zufrieden. Nur: Es gibt keinen einzigen Test. Jede Änderung fühlt sich an wie eine Operation am offenen Herzen im Dunkeln. Genau hier fangen die meisten Modernisierungsprojekte an — und genau hier machen viele den ersten Fehler.
Bevor wir über Werkzeuge reden, ein Satz vorweg: Ein System, das jahrelang zuverlässig läuft, ist kein Müll. Es ist der Beweis, dass die Fachlogik funktioniert. Das Problem ist fast nie die Logik selbst, sondern der Code drumherum — die Verflechtung, die fehlende Struktur, die Angst vor der nächsten Änderung. Tests sind das Mittel, um diese Angst systematisch abzubauen, ohne die bewährte Logik anzutasten.
Warum Legacy Code Tests anders funktionieren als Tests im Neubau
Im Lehrbuch schreibt man erst den Test, dann den Code. In Legacy-Code ist das umgekehrt: Der Code ist da, er funktioniert, und Sie wissen oft nicht genau, warum er funktioniert. Michael Feathers hat das in "Working Effectively with Legacy Code" auf den Punkt gebracht: Legacy-Code ist schlicht Code ohne Tests. Und der Kern der Aufgabe ist nicht, sauberen Code zu schreiben, sondern zunächst überhaupt einen Punkt zu finden, an dem Sie testen können — eine sogenannte Nahtstelle (seam).
Das große Hindernis in typischem PHP-Legacy-Code sind harte Abhängigkeiten: new PDO(...) mitten in der Methode, statische Aufrufe auf Datenbank-Wrapper, $_SESSION und $_POST direkt in der Fachlogik, date('Y-m-d') für "heute". Jede dieser Stellen macht eine Klasse für sich allein nicht testbar. Der erste Job besteht also nicht im Testen, sondern im Aufbrechen genau dieser Fesseln — und zwar so vorsichtig wie möglich.
Der falsche und der richtige Einstieg
Der falsche Einstieg ist der Versuch, "erst mal sauberen Unit-Test-Boden" herzustellen, indem Sie eine zentrale Klasse refactoren, damit sie testbar wird. Sie ändern Code ohne Netz, um ein Netz zu bauen. Das ist genau die riskante Operation, die Sie vermeiden wollten.
Der richtige Einstieg sind Characterization Tests — auf einer Ebene, die Sie ohne Code-Änderung erreichen. Ein Characterization Test beschreibt nicht, wie sich der Code verhalten soll, sondern wie er sich tatsächlich verhält. Sie zementieren das Ist-Verhalten, inklusive seiner Macken, damit Sie später gefahrlos umbauen können.
Konkret heißt das: Fangen Sie so weit außen an wie nötig und so weit innen wie möglich. Wenn eine einzelne Klasse nicht isolierbar ist, testen Sie den ganzen Use Case über die Kante — den HTTP-Endpunkt, den Konsolen-Befehl, die Service-Methode, die mehrere Klassen orchestriert. In Symfony leistet der WebTestCase genau das, in Laravel die HTTP-Tests mit $this->post(...). Sie schicken eine echte Anfrage rein und prüfen die Antwort und den Zustand der Datenbank danach. Das ist kein reiner Unit-Test, aber es ist ein Sicherheitsnetz — und das ist es, worauf es zuerst ankommt.
Ein pragmatischer Ablauf für die erste Absicherung
- Nehmen Sie die riskanteste Änderung, die ansteht — nicht die schönste Klasse. Tests haben nur dann sofortigen Wert, wenn sie den Bereich absichern, den Sie als Nächstes anfassen müssen.
- Schreiben Sie einen End-to-End- oder Integrationstest um diesen Bereich. Echte Eingabe rein, echtes Ergebnis raus. Bei der Datenbank nutzen Sie eine Testdatenbank oder Transaktionen, die nach jedem Test zurückgerollt werden (in Laravel
RefreshDatabase, in Symfony ein Rollback imtearDown). - Lassen Sie den Test das Ist-Verhalten festschreiben. Wenn Sie das erwartete Ergebnis nicht kennen, lassen Sie den Test scheitern, lesen den tatsächlichen Wert aus der Fehlermeldung und tragen ihn ein. Ja, wirklich. Sie dokumentieren die Realität, nicht den Wunsch.
- Erst jetzt refactoren Sie — die harten Abhängigkeiten heraus, hinter Interfaces, per Konstruktor-Injection. Jetzt haben Sie ein Netz, das anschlägt, wenn Sie das Verhalten aus Versehen ändern.
- Ergänzen Sie schnelle Unit-Tests für die nun isolierten Teile. Die langsamen Integrationstests bleiben als Rückversicherung bestehen.
Typische Fallen bei Legacy Code Tests
Zeit und Zufall. date(), time(), rand() und uniqid() machen Tests unzuverlässig. Ziehen Sie diese Werte in einen injizierbaren Clock- oder Randomizer-Service. Symfony bringt mit symfony/clock eine MockClock mit; das ist oft der günstigste erste Refactoring-Schritt überhaupt, weil er klein ist und viele Tests auf einmal stabil macht.
Globaler Zustand. $_SESSION, $_GET, statische Caches und Singletons, die zwischen Tests bestehen bleiben, führen zu Tests, die einzeln grün und zusammen rot sind. Kapseln Sie den Zugriff und setzen Sie den Zustand pro Test sauber zurück.
Die Versuchung, alles zu mocken. In Legacy-Code sind übermäßige Mocks gefährlich: Sie testen dann Ihre Annahme über den Code, nicht den Code. Gerade am Anfang ist ein Test gegen eine echte Testdatenbank mehr wert als ein Dutzend Mock-Erwartungen, die nur Ihr Weltbild bestätigen.
Coverage als Selbstzweck. Eine hohe Zeilenabdeckung sagt wenig, wenn niemand die Zusicherungen prüft. Wertvoller ist es zu wissen: Fängt die Suite den Fehler, den ich gleich einbauen könnte? Ein kurzer Blick mit einem Mutation-Testing-Werkzeug wie Infection beantwortet das ehrlicher als jede Prozentzahl.
Wo Sie den Hebel ansetzen
Sie werden eine Anwendung mit zehntausenden Zeilen nicht in einem Sprint testbar machen, und das ist auch nicht das Ziel. Das Ziel ist, dass jede Änderung ein Stück sicherer wird als die vorige. Priorisieren Sie nach Risiko und Änderungshäufigkeit: Der Code, der oft angefasst wird und bei dem ein Fehler teuer wäre — Preisberechnung, Rechnungsstellung, Zahlungsabwicklung, Rechteprüfung — verdient das erste Netz. Der Code, der seit fünf Jahren unverändert läuft und den niemand anfasst, kann warten.
Und noch einmal, weil es der Kern ist: Sie schreiben diese Tests nicht, weil das alte System schlecht ist. Sie schreiben sie, weil das alte System zu wertvoll ist, um es blind zu verändern — oder gar wegzuwerfen. Eine Neuentwicklung ist fast immer die teuerste und riskanteste Option, weil sie genau die bewährte Logik neu erfinden muss, die bereits funktioniert. Tests sind der günstigere, ehrlichere Weg.
Praxis-Tipp zum Schluss: Starten Sie diese Woche mit genau einem Test — einem Integrationstest um die nächste anstehende Änderung. Nicht um die schönste Klasse, nicht um eine perfekte Suite. Ein Test, der Sie beim nächsten Deploy ruhiger schlafen lässt. Der Rest wächst von dort aus.
Wenn Sie an genau diesem Punkt feststecken — viel Fachlogik, kein Netz, hoher Änderungsdruck — begleite ich mit LegacyWerk solche Projekte und helfe, die ersten tragfähigen Tests dort zu setzen, wo sie den größten Unterschied machen.