Zum Inhalt springen
Abstrakte Formen aus Glas und dunklem Papier: ein alter unregelmäßiger Block in einem präzisen neuen Rahmengerüst

Legacy-Code ohne Tests: So sichern Sie Änderungen ab

Legacy-Code ändern Sie sicher, wenn Tests vorher festhalten, was er tatsächlich tut, auch wo das Ergebnis falsch aussieht. Solche Charakterisierungstests entstehen zuerst an der Stelle, die sich als Nächstes ändert, und erst danach beginnt der Umbau.

7 min Lesezeit Laravel & PHP

Michael Feathers, Autor des Standardwerks „Working Effectively with Legacy Code“ (Prentice Hall, 2004), definiert Legacy-Code im Vorwort ohne Bezug auf Alter oder Sprache: Für ihn ist Legacy-Code Code ohne Tests. Nach dieser Definition ist auch eine Anwendung aus dem Vorjahr Altlast, wenn niemand prüfen kann, ob eine Änderung etwas anderes kaputt macht.

Gemeint sind hier Anwendungen, die laufen und Geld verdienen: ein Kundenportal mit PHP 7.4, eine Auftragsverwaltung, die ein früherer Entwickler gebaut hat, eine Preislogik, die seit Jahren niemand angefasst hat. Solche Systeme erfüllen ihren Zweck. Was ihnen fehlt, ist eine Absicherung, und ohne sie wird jede Änderung teurer, als sie sein müsste.

Hintergrund

Warum Altsoftware Änderungen scheut

Ohne Tests prüft nur ein Mensch, ob nach einer Änderung noch alles funktioniert. Meist ist das die Person, die das System am besten kennt, und sie klickt die Stellen durch, an die sie gerade denkt. Was sie vergisst, fällt erst im Betrieb auf: beim Monatsabschluss, beim nächsten Export, beim Kunden mit dem Sonderfall.

Daraus entstehen Ausweichbewegungen, die das System weiter verschlechtern. Neue Funktionen werden neben den alten Code gebaut statt hinein, Fehler werden umgangen statt behoben, und das Wissen bleibt bei einer Person. Was passiert, wenn diese Person ausfällt, beschreibt der Beitrag zum Bus-Faktor im Softwareprojekt.

Feathers nennt das Grundproblem das Dilemma des Legacy-Codes: Wer Code ändert, sollte Tests haben. Um Tests zu schreiben, muss man aber oft erst den Code ändern, etwa weil eine Funktion direkt auf die Datenbank, die Uhrzeit oder eine globale Variable zugreift. Der Ausweg ist eine Reihenfolge: zuerst kleine, vorsichtige Eingriffe, die nur Testbarkeit herstellen, dann Tests, dann die eigentliche Änderung.

Ein Beispiel für einen solchen Eingriff: Eine Funktion, die intern das aktuelle Datum abfragt, liefert an jedem Tag ein anderes Ergebnis und lässt sich so nicht prüfen. Der kleinste Umbau übergibt das Datum als zusätzlichen, optionalen Parameter, dessen Standardwert das bisherige Verhalten erzeugt. Im Betrieb ändert sich nichts, der Test kann aber ein festes Datum setzen, etwa den 31.12. für Fristen über den Jahreswechsel.

Ein oranger Knetblock mit dem Abdruck eines Schlüssels, der helle Schlüssel liegt daneben: festhalten, was ist.

Ist-Zustand festhalten

Charakterisierungstests: festhalten, was ist

Ein Charakterisierungstest beschreibt, was der Code tatsächlich tut, nicht was er tun sollte. Feathers führt den Begriff im Kapitel über die Frage ein, welche Tests man schreiben soll, wenn keine Spezifikation existiert: Die Erwartung kommt dann aus dem Verhalten des Codes selbst.

Das Vorgehen hat vier Schritte:

  1. Den Code in einem Test aufrufen, mit Eingaben aus dem echten Betrieb.

  2. Eine Erwartung hineinschreiben, von der Sie wissen, dass sie nicht stimmt, zum Beispiel einen leeren Text.

  3. Den Test laufen lassen. Die Fehlermeldung nennt den tatsächlichen Wert.

  4. Diesen Wert als Erwartung übernehmen. Der Test wird grün und hält das Verhalten fest.

Der wichtigste Grundsatz dabei: Falsch aussehende Ergebnisse werden zunächst ebenfalls festgehalten. Berechnet die alte Versandlogik für ein Paket mit null Kilogramm den vollen Preis, steht genau das im Test, mit einem Kommentar. Korrigiert wird später, als eigene Änderung mit eigener Entscheidung. Sonst verschwimmen Absicherung und Fehlerbehebung, und niemand weiß, welche Änderung welches Ergebnis verschoben hat.

Die passende Werkzeugversion richtet sich nach der PHP-Version der Anwendung. Laut phpunit.de verlangt PHPUnit 13 (erschienen am 06.02.2026) mindestens PHP 8.4, PHPUnit 12 PHP 8.3, PHPUnit 11 PHP 8.2, PHPUnit 10 PHP 8.1 und PHPUnit 9 PHP 7.3. Fehlerbehebungen bekommen am 04.06.2026 nur noch PHPUnit 12 und 13; für PHPUnit 9 endeten sie am 02.02.2024, für 10 am 07.02.2025 und für 11 am 06.02.2026. Pest 4, die Alternative mit kürzerer Schreibweise, setzt PHP 8.3 voraus und baut auf PHPUnit 12 auf. Eine Anwendung auf PHP 7.4 bekommt also PHPUnit 9.6. Für Tests, die nur in der Entwicklung und in der automatischen Prüfung laufen, halten wir das für vertretbar, bis die Anwendung selbst auf eine neuere PHP-Version wechselt.

Terminal (im Projektverzeichnis, je nach PHP-Version eine Zeile) bash
# Anwendung auf PHP 7.3 bis 8.0
composer require --dev phpunit/phpunit:^9.6

# Anwendung auf PHP 8.1
composer require --dev phpunit/phpunit:^10.5

# Anwendung auf PHP 8.2
composer require --dev phpunit/phpunit:^11.5

# Anwendung auf PHP 8.3
composer require --dev phpunit/phpunit:^12.0

# Anwendung auf PHP 8.4 oder neuer
composer require --dev phpunit/phpunit:^13.0

# Einen einzelnen Test ausführen
vendor/bin/phpunit tests/ShippingCostCharacterizationTest.php

Beispiel

Ein Charakterisierungstest in PHPUnit

Das Beispiel sichert eine alte Funktion ab, die Versandkosten berechnet. Die erwarteten Werte stammen aus einem ersten Testlauf nach den vier Schritten oben. Zwei Fälle sehen falsch aus und sind trotzdem festgehalten.

Der Test nutzt assertSame statt assertEquals, und das hat einen Grund: assertEquals vergleicht lose, die Prüfung assertEquals(5, '5') besteht. assertSame prüft Wert und Typ. Gibt die Funktion nach einer Änderung eine Zahl statt eines Textes zurück, schlägt nur assertSame an, und genau solche stillen Typwechsel verursachen in altem PHP-Code Folgefehler, etwa in Vergleichen oder beim Formatieren von Beträgen.

tests/ShippingCostCharacterizationTest.php (PHPUnit 10 bis 13; unter PHPUnit 9 steht statt des Attributs die Annotation @dataProvider) php
<?php

declare(strict_types=1);

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

require_once __DIR__ . '/../legacy/shipping.php';

/**
 * Characterization test: records what legacy_shipping_cost() returns today.
 * Expected values come from running the code, not from a specification.
 */
final class ShippingCostCharacterizationTest extends TestCase
{
    public static function observedCases(): array
    {
        return [
            'light parcel, Germany' => [0.5, 'DE', '4.90'],
            'exactly 2 kg, Germany' => [2.0, 'DE', '4.90'],
            'just above 2 kg'       => [2.01, 'DE', '6.90'],
            'Austria'               => [1.0, 'AT', '12.50'],
            // Looks wrong, but this is current behaviour. Change it later, on purpose.
            'zero weight'           => [0.0, 'DE', '4.90'],
            'unknown country code'  => [1.0, 'XX', '0.00'],
        ];
    }

    #[DataProvider('observedCases')]
    public function testShippingCostMatchesObservedBehaviour(
        float $weightKg,
        string $country,
        string $expected
    ): void {
        $this->assertSame($expected, legacy_shipping_cost($weightKg, $country));
    }
}

Reihenfolge

Die riskantesten Stellen zuerst

Ob sich das Nachrüsten auszahlt, hängt davon ab, ob sich der Code weiter ändert. Für Code, den niemand mehr anfasst, sind Tests selten den Aufwand wert; für Code, der sich ändert, rechnen sie sich bei der ersten Änderung, die ohne Test einen Fehler im Betrieb verursacht hätte. Deshalb muss niemand eine ganze Altanwendung auf einmal mit Tests versehen. Sinnvoll ist eine Reihenfolge nach Risiko, und drei Fragen bestimmen sie.

Wo ändert sich als Nächstes etwas? Tests gehören zuerst um den Code, den Sie als Nächstes anfassen. Feathers spricht von Änderungspunkten: Dort entsteht das Risiko, und dort zahlt sich ein Test sofort aus.

Wo geht es um Geld, Fristen oder Rechte? Preise, Rabatte, Steuern, Rechnungsnummern, Fristberechnungen und Freigaben. Ein Beispiel für diese Art Code ist das Workflow-Portal mit handcodierter State-Logik, das wir 2012 gebaut haben: State-Machines, Übergänge, Rollen und Audit-Trail sind dort eigener Code, geliefert als Festpreis in vier abgenommenen Raten. In solcher Logik ist jeder erlaubte und jeder verbotene Statusübergang eine Geschäftsregel. Je ein Test pro Übergang hält fest, was die Plattform tut, bevor jemand eine neue Regel einbaut.

Wo ändert sich am häufigsten etwas? Die Versionsverwaltung kennt die Antwort. Dieser Befehl zählt, welche Dateien seit einem Stichtag am häufigsten geändert wurden; das Datum passen Sie an:

Terminal (Git, häufig geänderte Dateien seit 01.06.2025) bash
git log --since=2025-06-01 --name-only --pretty=format: \
  | grep -v '^$' | sort | uniq -c | sort -rn | head -20

Ganze Ausgaben

Wenn eine Funktion zu groß für Einzelfälle ist

Manche Stellen liefern ganze Seiten, PDFs oder Exporte. Für sie eignet sich ein Gesamtvergleich: Der Test speichert die Ausgabe beim ersten Lauf und vergleicht jeden weiteren Lauf damit. In Pest heißt das toMatchSnapshot, die Vergleichsdateien liegen unter tests/.pest/snapshots. In PHPUnit erreichen Sie dasselbe mit assertStringEqualsFile gegen eine gespeicherte Datei.

Wechselnde Teile wie Datum, Uhrzeit, Zufallswerte oder Sicherheitstokens müssen vorher ersetzt werden, sonst schlägt der Vergleich bei jedem Lauf an. Die Pest-Dokumentation zeigt das am Beispiel des CSRF-Tokens in Formularen.

Zeigen die Tests, dass ein Modul eher ersetzt als repariert werden sollte, ist der nächste Schritt eine Modernisierung in Etappen nach dem Strangler-Fig-Muster, das Martin Fowler beschrieben hat: Neue Module wachsen neben dem alten System und übernehmen dessen Aufgaben Stück für Stück. Die Charakterisierungstests prüfen dabei, ob das neue Modul dasselbe liefert wie das alte.

Gewohnheit

Tests in den Alltag einbauen

Nachgerüstete Tests helfen nur, wenn sie bei jeder Änderung laufen. Drei Regeln halten das am Leben:

  1. Jede Fehlerbehebung beginnt mit einem Test, der den Fehler nachstellt und rot ist. Danach wird korrigiert, bis er grün ist. So wächst die Testsammlung genau dort, wo die Anwendung nachweislich bricht.

  2. Vor jeder Änderung an ungetestetem Code kommen zuerst Charakterisierungstests für diese Stelle. Ändert sich ein festgehaltenes Ergebnis absichtlich, wird die Erwartung bewusst angepasst und der Grund in der Commit-Nachricht notiert.

  3. Die Tests laufen automatisch bei jedem Push und nicht allein auf dem Rechner des Entwicklers. Wie das mit wenig Aufwand gelingt, zeigt GitHub Actions für Laravel und Statamic.

Eine Kennzahl für die Testabdeckung dient dabei zur Orientierung. Ein Test, der eine Preisberechnung mit zehn echten Fällen prüft, schützt mehr als zwanzig Tests für Hilfsfunktionen, die nie jemand ändert.

Der Nutzen zeigt sich spätestens bei Updates. Warum kleine, regelmäßige Aktualisierungen günstiger sind als seltene große, begründet der Beitrag zur Patch-Disziplin. Ohne Tests bleibt bei jedem Update nur das Durchklicken. In der Wartung übernommener Bestandssysteme gehört das Nachziehen von Tests bei uns deshalb dazu, zusammen mit rückwirkender Dokumentation und geplanten Versionssprüngen.

Erst absichern, dann umbauen

Wenn ein Modul ersetzt werden soll, liefert der zweiwöchige Modernisierungs-Sprint einen migrierbaren Modulplan und einen Piloten nach dem Strangler-Fig-Muster. Die Stelle, vor deren Änderung Ihnen am meisten graut, ist ein guter Ausgangspunkt dafür.

Zum Modernisierungs-Sprint

Nachweise

Quellen und Stand

Stand: 04.06.2026. Versionen und Supportzeiträume laut phpunit.de und der Pest-Dokumentation zu Pest 4.

Weiterlesen: PHP 8.2 Supportende (20.07.2026) nennt die Termine für ältere PHP-Versionen. Laravel-Upgrade planen (29.07.2026) zeigt, wie Tests ein Framework-Upgrade absichern. Staging-Umgebung (03.08.2026) erklärt, wo Änderungen vor dem Livegang geprüft werden.