Alle Artikel
12. September 2026
10 mins

Legacy-Code testen, wenn es keine Tests gibt

Von Tim Rutte, Cloud & Software ArchitectThemaLegacy & Modernisierung

Ein straff gespanntes weißes Sicherheitsnetz an blauen Seilen, darüber ein leeres Trapez.

Die Anfrage klingt immer ähnlich. Ein System läuft seit zwölf Jahren, verdient jeden Tag Geld, und niemand traut sich mehr, etwas daran zu ändern. Tests gibt es keine. Die Frage lautet: Sollen wir erst Tests schreiben oder gleich anfangen?

Die Antwort ist beides und keines von beidem. Tests schreiben, ja. Aber nicht die Tests, an die dabei alle denken, und nicht für das ganze System. Sondern eine ganz bestimmte Sorte Test, an einer ganz bestimmten Stelle, und danach sofort weiterarbeiten.

Dieser Artikel beschreibt, wie das geht: wie man Verhalten festhält, das niemand mehr erklären kann, ohne es vorher zu verstehen. Das Muster dahinter heißt Characterization Tests, und es dreht die übliche Reihenfolge um.

Warum Tests vor der ersten Änderung kommen

Der übliche Grund für Tests ist Qualität: Sie sollen Fehler finden. In einem Altsystem ist das nicht der Grund. Der Grund ist ein Vergleichsmaßstab.

Wenn Sie an einem System, das niemand mehr versteht, etwas ändern, haben Sie hinterher zwei Möglichkeiten. Entweder Sie hoffen, dass nichts kaputtgegangen ist, oder Sie wissen es. Der Unterschied zwischen beiden ist ein Satz Tests, der vor der Änderung schon einmal grün war.

Daraus folgt etwas Unangenehmes, an dem sich die Geister scheiden: Diese Tests halten das jetzige Verhalten fest, auch wenn es falsch ist. Wenn die Rabattberechnung bei einem Warenkorbwert von exakt null Euro ein Prozent Rabatt gewährt, dann schreibt der Test genau das fest. Nicht, weil es richtig wäre, sondern weil irgendwo ein Kunde, ein Bericht oder ein anderes System auf dieses Verhalten gebaut hat, und Sie werden es erst erfahren, wenn Sie es ändern.

Ein Test, der das dokumentiert, ist kein schlechter Test. Er ist ein Protokoll. Und in dem Moment, in dem jemand entscheidet, dass das Verhalten falsch ist, wird aus dem Protokoll ein absichtlich geänderter Test, mit einer Zeile Begründung darüber. Das ist der Punkt, an dem ein Altsystem anfängt, wieder erklärbar zu werden.

Was Sie festhalten: Verhalten, nicht Absicht

Der übliche Fehler beim ersten Versuch besteht darin, zu lesen, was der Code tun soll, und das zu testen. Das ist die Absicht. Die Absicht steht nirgends, sie wird beim Lesen erfunden, und ein Test, der sie prüft, schlägt fehl, ohne dass ein Fehler vorliegt.

Festgehalten wird stattdessen, was tatsächlich herauskommt. Das Vorgehen ist mechanisch und braucht kein Verständnis des Codes:

  1. Die Funktion aufrufen mit einer Eingabe, die im Betrieb vorkommt.
  2. Eine falsche Erwartung hinschreiben, zum Beispiel assertSame('WIRD_SCHEITERN', $ergebnis).
  3. Den Test laufen lassen. Er schlägt fehl und nennt dabei den tatsächlichen Wert.
  4. Den tatsächlichen Wert eintragen. Jetzt ist der Test grün und beschreibt das System.

Das fühlt sich falsch an, weil es die Testpyramide auf den Kopf stellt. Es ist trotzdem richtig, und der Grund steht oben: Sie schreiben keinen Test, um eine Anforderung zu prüfen. Sie schreiben ihn, um eine Änderung zu ermöglichen.

Der Golden Master: viele Fälle auf einmal

Für eine einzelne Funktion reicht der Weg oben. Für einen Rechnungslauf, einen Export oder eine Preisberechnung mit dreißig Sonderfällen nicht. Dort wäre jeder Fall ein eigener Test, und niemand schreibt dreißig davon von Hand.

Der Golden Master löst das anders: Ein Lauf erzeugt eine vollständige Ausgabe, die Ausgabe wird gespeichert, und jeder spätere Lauf wird dagegen verglichen. Ändert sich ein Zeichen, schlägt der Test fehl und zeigt genau, welches.

In PHP sieht das in der einfachsten Form so aus:

public function testRechnungslaufUnveraendert(): void
{
    $ausgabe = (new Rechnungslauf())->fuerMonat('2026-06')->alsText();

    $referenz = __DIR__ . '/referenz/rechnungslauf-2026-06.txt';

    // Beim ersten Lauf entsteht die Referenz. Danach wird nur noch verglichen.
    if (!file_exists($referenz)) {
        file_put_contents($referenz, $ausgabe);
        self::markTestIncomplete('Referenz angelegt, bitte pruefen und einchecken.');
    }

    self::assertSame(file_get_contents($referenz), $ausgabe);
}

Zwei Dinge daran sind wichtiger, als sie aussehen.

Die Referenzdatei gehört ins Repository, und sie wird beim ersten Mal gelesen, nicht nur erzeugt. Eine Referenz, die niemand angesehen hat, schreibt jeden vorhandenen Fehler fest, ohne dass irgendjemand die Chance hatte, ihn zu bemerken. Fünfzehn Minuten Durchsehen sind hier gut investiert.

Der Test schlägt beim ersten Lauf bewusst mit markTestIncomplete fehl. Sonst entsteht die Referenz still, der Test ist grün, und niemand merkt, dass gerade nichts geprüft wurde.

Wo die Ausgabe kein Text ist, sondern ein Objektbaum, ist JSON die bessere Referenz: sortiert, mit lesbaren Zeilenumbrüchen, damit der Unterschied im Fehlerbericht eine Zeile ist und nicht eine einzige lange Zeichenkette.

Wo Sie anfangen, und wo nicht

Die naheliegende Antwort wäre: dort, wo der Code am schlimmsten ist. Das ist die falsche Antwort, weil sie Aufwand nach Ärger verteilt statt nach Nutzen.

Die richtige Antwort steht in der Versionsgeschichte. Getestet wird zuerst, was am häufigsten geändert wird, denn dort werden die Tests am häufigsten gebraucht:

git log --since="18 months ago" --name-only --pretty=format: \
  | grep '\.php$' | sort | uniq -c | sort -rn | head -20

Die zweite Liste ist die des anstehenden Vorhabens. Wenn in vier Wochen das Preismodell geändert wird, gehören die Tests um die Preisberechnung herum, auch wenn sie seit drei Jahren niemand angefasst hat. Ein Sicherheitsnetz spannt man dort, wo jemand springen wird.

Und dann gibt es Code, den Sie nicht testen sollten. Ein Modul, das in achtzehn Monaten nicht geändert wurde, an das niemand heranwill und das funktioniert, braucht keine Tests. Es braucht eine Notiz, dass es nicht getestet ist. Der Unterschied zwischen beidem sind Wochen.

Tests sind nicht das einzige Netz. Was eine statische Prüfung in Altcode findet, ohne dass eine Zeile ausgeführt wird, steht in PHPStan Stufe für Stufe in Altcode einführen.

Nahtstellen finden, ohne den Code umzubauen

Hier wird es in der Praxis unangenehm. Die Funktion, die Sie testen wollen, ruft in Zeile drei eine Datenbank auf, in Zeile zwölf einen Webdienst und in Zeile zwanzig date(). Testen lässt sich das so nicht, und umbauen dürfen Sie es nicht, weil Sie ja gerade den Schutz herstellen, den ein Umbau braucht.

Der Ausweg heißt Nahtstelle: eine Stelle, an der sich Verhalten austauschen lässt, ohne die Stelle selbst zu ändern. Drei davon funktionieren fast immer, und alle drei sind bewusst klein.

Die Unterklasse im Test. Die störende Methode wird in der Testklasse überschrieben. Dafür muss sie protected statt private sein, und genau das ist die einzige Änderung am Produktionscode, die ich an dieser Stelle erlaube. Sie ist klein, sie ist rückgängig zu machen, und sie ändert kein Verhalten.

final class PreisRechnerOhneDatenbank extends PreisRechner
{
    protected function ladeStaffel(int $kundeId): array
    {
        // Fest verdrahtet, was in Produktion aus der Datenbank kaeme.
        return [10 => 0.05, 50 => 0.10, 100 => 0.15];
    }
}

Die Zeit als Parameter. date() und time() mitten im Code machen jeden Test vom Kalender abhängig. Ein zusätzlicher Parameter mit Vorgabewert ändert keinen einzigen Aufrufer und macht den Test reproduzierbar: public function faelligAm(Bestellung $b, ?DateTimeImmutable $jetzt = null).

Der Test auf der Ebene darüber. Wenn eine Funktion sich nicht isolieren lässt, testen Sie nicht sie, sondern den Aufruf, der sie enthält: den HTTP-Endpunkt, den Konsolenbefehl, den Cronjob. Solche Tests sind langsamer und ungenauer, aber sie brauchen keine einzige Änderung am Produktionscode, und sie fangen genau die Fehler, um die es geht.

Wer an dieser Stelle anfängt, Abhängigkeiten sauber einzuspritzen und Schnittstellen einzuführen, hat den Artikel falsch verstanden. Das ist die Arbeit nach dem Netz, nicht davor. Die Reihenfolge zu tauschen ist derselbe Fehler wie ein Neubau, nur kleiner.

Zeit, Zufall und die Datenbank

Drei Dinge machen aus einem festgehaltenen Verhalten einen Test, der mal grün und mal rot ist. Ein solcher Test ist schlimmer als keiner, weil er nach drei Wochen ignoriert wird.

Zeitstempel in der Ausgabe. Rechnungsdatum, Erstellungszeitpunkt, eine Laufzeit in Millisekunden. Die werden vor dem Vergleich ersetzt, nicht aus der Ausgabe entfernt: Dass ein Datum dasteht, soll der Test weiter prüfen, nur eben nicht welches.

$vergleichbar = preg_replace(
    '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/',
    '<ZEITSTEMPEL>',
    $ausgabe
);

Erzeugte Bezeichner. Autoinkrement-Nummern, UUIDs, Sitzungsschlüssel. Dieselbe Behandlung, mit einem Unterschied: Wenn derselbe Bezeichner mehrfach in der Ausgabe vorkommt und die Beziehung wichtig ist, ersetzen Sie ihn durchnummeriert, nicht alle gleich.

Reihenfolge ohne ORDER BY. Der Klassiker. Die Abfrage liefert heute dieselbe Reihenfolge wie gestern, weil die Tabelle klein ist und der Optimierer sich nicht anders entscheidet. Sie liefert eine andere, sobald der Ausführungsplan wechselt. Sortieren Sie im Test, bevor Sie vergleichen, und schreiben Sie sich die Stelle auf: Eine Abfrage ohne ORDER BY, deren Reihenfolge irgendwo eine Rolle spielt, ist ein Fehler, der auf seinen Tag wartet.

Für die Datenbank selbst gilt: Der Test braucht einen bekannten Zustand, nicht einen frischen. Ein Abzug aus der Produktion, anonymisiert und auf wenige Datensätze gekürzt, ist als Grundlage besser als handgeschriebene Testdaten, weil er die Fälle enthält, die sich niemand ausdenkt. Wichtig ist nur, dass jeder Lauf ihn zurücksetzt, sonst hängt der zweite Lauf vom ersten ab.

Zwei Tage, ein Beispiel

Damit das nicht theoretisch bleibt, hier die typische Abfolge, wie sie in der Praxis aussieht, wenn ein Preismodul geändert werden soll.

Vormittag eins. Versionsgeschichte auswerten, die drei Dateien bestimmen, die das Modul ausmachen. Feststellen, dass die Berechnung über einen Konsolenbefehl erreichbar ist. Das ist die Ebene, auf der getestet wird, ohne eine Zeile Produktionscode anzufassen.

Nachmittag eins. Vierzig echte Eingaben aus den Protokollen der letzten Woche ziehen, den Befehl damit laufen lassen, die Ausgaben als Referenzdateien ablegen. Die drei auffälligsten durchlesen. Dabei fällt der erste Fehler auf, und zwar einer, der seit Jahren im Betrieb ist und über den nie jemand gestolpert ist.

Vormittag zwei. Zeitstempel und Bezeichner ersetzen, bis drei Läufe hintereinander grün sind. Das ist der Teil, der immer länger dauert als geplant.

Nachmittag zwei. Die eigentliche Änderung. Jetzt zeigt der Test bei jedem Lauf, welche der vierzig Ausgaben sich geändert hat. Erwartet waren sechs. Es sind neun. Die drei zusätzlichen sind der Grund, warum das Netz vorher gespannt wurde.

Zwei Tage sind keine Schätzung für Ihr System. Sie sind die Größenordnung, um die es geht: Tage, nicht Wochen, und der Ertrag fällt sofort an.

Diese Arbeit ist stumpf und umfangreich, und genau deshalb eignet sie sich für einen Agenten: Coding-Agenten im Altsystem.

Wann Sie aufhören

Es gibt keinen Zielwert. Testabdeckung ist eine Zahl, die misst, welche Zeilen ausgeführt wurden, nicht welche geprüft. Achtzig Prozent an den falschen Stellen sind weniger wert als zwanzig an den richtigen.

Aufgehört wird, wenn drei Sätze stimmen. Die Änderung, die ansteht, ist umgeben von Tests, die vorher grün waren. Ein Lauf dauert kurz genug, dass ihn jemand vor dem Commit startet, also unter zwei Minuten. Und jeder im Team kann den Lauf mit einem Befehl starten, ohne vorher etwas einzurichten.

Alles darüber hinaus ist kein Sicherheitsnetz mehr, sondern ein eigenes Projekt, und dafür gibt es in einem Altsystem fast nie ein Budget. Was Sie stattdessen tun: Jedes Mal, wenn jemand etwas ändert, kommt das Netz um diese Stelle dazu. Nach einem Jahr ist die Abdeckung genau dort hoch, wo gearbeitet wird, und genau dort niedrig, wo nichts passiert. Das ist kein Kompromiss, das ist das Ziel.

Und wenn Sie das System gerade erst übernommen haben und noch gar nicht wissen, wo gearbeitet wird: wie eine geordnete Übernahme abläuft, steht auf einer eigenen Seite.

Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.