Das Bild zeigt einen Flugschreiber, den man oft als "Black Box" bezeichnet, der auf dem Meeresboden liegt. Das orangefarbene, zylinderförmige Gerät ist ein bisschen verrostet und auf einer Grundplatte montiert, mit einem rechteckigen Teil dahinter. Auf dem Zylinder ist ein großes weißes Etikett mit der Aufschrift "FLIGHT RECORDER DO NOT OPEN".

Dieser Artikel basiert auf einem Vortrag, den ich letzten Monat auf der betterCode() PHP Konferenz zum ersten Mal gehalten habe.

Wer kennt sie nicht, diese Situation: Ein altes PHP-Projekt mit veralteter Dokumentation, Klassen ohne Tests, globalen Variablen, magischen Methoden und verworrenen Abhängigkeiten. Niemand kennt das tatsächliche Verhalten des Codes genau. Und wenn ein PHP-Upgrade ansteht, wird die Angst greifbar. Wie können wir in dieser Situation sichere Änderungen vornehmen?

In einem vorherigen Artikel habe ich darüber geschrieben, dass Test Oracles die Wahrheit kennen und uns sagen können, ob unsere Tests erfolgreich sind. Aber es gibt ein kleines Problem: Was machen wir, wenn das Orakel nicht zu uns spricht? Wenn es keine Ahnung hat, was das System eigentlich tun sollte? Dann benötigen wir die Fähigkeit, die Vergangenheit zu dokumentieren, um den Weg in die Zukunft zu finden. Und genau darum soll es in diesem Artikel gehen.

Die zentrale These ist einfach, aber kraftvoll: Wir müssen Tests in unseren Code einführen, um ihn ändern zu können. Um Tests einzuführen, muss jedoch oft zuerst der Code geändert werden. Dies ist das klassische Dilemma der Modernisierung von Legacy Code. Und es ist lösbar.

Warum wir Software ändern

Bevor wir uns mit Lösungen beschäftigen, ist es wichtig zu verstehen, warum der Code geändert werden muss:

  • Features hinzufügen
  • Bestehende Features ändern oder entfernen
  • Fehler beheben
  • Design verbessern (Refactoring)
  • Performance optimieren
  • Auf Änderungen im technologischen Stack reagieren (beispielsweise PHP-Upgrade)

Jede dieser Änderungen birgt das Risiko unerwünschter Nebenwirkungen. Ohne Tests ist dieses Risiko kaum kontrollierbar.

Characterization Tests

Die Lösung heißt Characterization Test. Ein Ansatz, der das tatsächliche Verhalten des Codes dokumentiert, ohne zu beurteilen, ob dieses Verhalten "richtig" ist. Dies ist der pragmatische Weg, um überhaupt erst Testabdeckung in Legacy-Code zu schaffen:

  1. Wähle einen Teil des Codes aus, den du testen möchtest
  2. Schreibe einen Test mit einer Zusicherung (Assertion), von der du weißt, dass sie fehlschlagen wird
  3. Führe den Test aus: die Fehlermeldung zeigt dir das tatsächliche Verhalten
  4. Ändere die Assertion so, dass sie den beobachteten Wert enthält
  5. Wiederhole dies mit verschiedenen Eingaben, bis du ausreichende Abdeckung hast

Diese Methode ist besonders wertvoll, da sie:

  • Das tatsächliche Verhalten des Codes durch Tests dokumentiert
  • Ein Sicherheitsnetz für zukünftige Änderungen schafft
  • Sogar Fehler oder Quirks des Codes erfasst, die später gezielt behoben werden können
  • Den Code praktisch "interviewt" – wir lernen sein Verhalten kennen, ohne es interpretieren zu müssen

Ein praktisches Beispiel ist eine einfache API, die GET- und POST-Anfragen verarbeitet. Mithilfe von Characterization Tests können wir zunächst das Verhalten charakterisieren. Dazu senden wir eine GET-Anfrage an die Software, die zuvor in einen eindeutig definierten Zustand versetzt wurde. Anschließend überprüfen wir die Antwort, sowohl die HTTP-Header als auch den JSON-Body.

Wir können einen Schritt weitergehen und die Erstellung unserer Characterization Tests zu einem gewissen Grad automatisieren, indem wir eine Request/Recorder-Middleware implementieren, die alle HTTP-Interaktionen protokolliert. Diese Middleware wird beim Start der Anwendung geladen und zeichnet für jede Anfrage die wichtigsten Informationen auf: URI, HTTP-Methode, Parameter und Payload werden ebenso erfasst wie die vollständige Response mit Body und Headern. All diese Daten werden abgespeichert, beispielsweise in einer JSON-Datei pro Request/Response-Zyklus.

Diese aufgezeichneten Request/Response-Datenpaare können dann über Data Provider in Tests verwendet werden. Das ist eine elegante Methode, um schnell eine umfangreiche Testabdeckung auf Basis echter Interaktion mit der Software zu erreichen. Die Tests durchlaufen alle aufgezeichneten Anfragen, senden sie erneut an die Anwendung und überprüfen, ob die neue Antwort mit der aufgezeichneten übereinstimmt. Auf diese Weise können Hunderte von realen Szenarien mit minimalem Aufwand automatisch getestet werden. In der Praxis ist das natürlich nicht ganz so einfach, da Seiteneffekte und der Ausgangszustand der Anwendung berücksichtigt werden müssen.

Im Material zu meinem Vortrag findest du entsprechenden Beispielcode:

Vom Blindflug zur sicheren Landung

Der Weg in die Zukunft

Der Schlüssel zur erfolgreichen Modernisierung von Legacy Code liegt in einem iterativen Prozess. Characterization Tests bilden dabei das essenzielle Sicherheitsnetz für den bestehenden Code und ermöglichen es, ihn systematisch in kleinere, testbare Einheiten zu refaktorieren. Nachdem dieses Sicherheitsnetz etabliert wurde, können die Characterization Tests als Akzeptanztests beibehalten und schrittweise durch spezifische, aussagekräftige Unit Tests ergänzt werden.

Das ist ein gut geplanter Weg, Schritt für Schritt. Die Luftfahrtmetapher verdeutlicht den Unterschied:

  • Tests vor dem Flug (TDD): Geplante, sichere Landung – optimaler Zustand
  • Tests nach dem Take Off: Notlandung – pragmatisch, aber leicht hektisch
  • Keine Tests: Bruchlandung – hochriskant

Legacy Code ist nicht dein Feind. Er ist wertvoll und hält oft geschäftskritische Funktionen zusammen. Mit den richtigen Werkzeugen und Strategien wird die Modernisierung zur beherrschbaren Aufgabe.