Da hatte ich mal wieder so einen ganz typischen Fall: Zugriff aufs Repository, und drin – PHP 7.2, 38.412 Zeilen Code und kein einziger Test. Mittendrin thront ein OrderProcessor::handle(), schlanke 823 Zeilen, neun verschachtelte if-Bäume und ein freundliches // TODO: refactor (2019).
Der Vorgänger? Längst weg. Die Doku? Drei Confluence-Seiten (immerhin) aus dem März 2017. Und die Aufgabe lautet, ganz lapidar: „Bitte sauber machen und auf PHP 8.3 heben.“ Klingt machbar, oder? Dachte ich auch.
Mein TDD-Reflex sagt: erst Tests, dann refactorn. Klingt nach Lehrbuch, ist auch eines. Funktioniert hier aber eben nicht. Klassisches TDD setzt halt voraus, dass ich weiß, was der Code soll. Und bei übernommenem Legacy-Code? Da weiß ich nur, was er tut – und auch das oft nur so ungefähr. Wenn ich da Tests gegen eine vermutete Spezifikation schreibe, baue ich keine Sicherheitsnetze, sondern Behauptungen. Beim ersten echten Refactor fliegen mir dann entweder die Tests oder Production um die Ohren.
Mein Einstieg heißt Characterization Test. Den Begriff hat Michael Feathers 2004 in Working Effectively with Legacy Code geprägt. Das Buch ist inzwischen 22 Jahre alt und immer noch das ehrlichste, was ich zu diesem Thema kenne. Die Idee ist absichtlich klein: Ich baue einen Test, der das gegenwärtige Verhalten des Codes festhält – inklusive aller Eigenheiten und Bugs. Der Test sagt eben nicht, was richtig ist. Er sagt nur, was jetzt passiert. Und sobald ich das fixiert habe, wird jede Verhaltensänderung beim Refactoring sichtbar. Genau das brauche ich, um die Sache überhaupt anfassen zu können.
Warum klassisches TDD hier nicht greift
In einer Greenfield-Codebase ist die Reihenfolge Test → Implementierung → Refactor sinnvoll, weil die Anforderung bekannt ist. In einer übernommenen Codebase eben nicht. Was ich da vorfinde, ist – grob:
Ein Bündel aus beabsichtigtem Verhalten, das niemand mehr aufschreibt. Ein Bündel aus unbeabsichtigtem Verhalten, das die Buchhaltung seit 2019 manuell korrigiert. Und ein Bündel aus Verhalten, das nur in Production auftritt, weil irgendein Kunde eine kaputte UTF-8-Konfiguration hat.
Wenn ich gegen die „richtige“ Spezifikation teste, liefere ich im besten Fall einen Patch. Im schlechtesten Fall mache ich etablierte Workarounds kaputt – Workarounds, von denen niemand wusste, dass sie etablierte Workarounds sind. Ich hab das in den letzten Jahren drei Mal in dieser Schärfe gesehen, am eindrucksvollsten im Herbst 2022 bei einer Logistik-Plattform. Damals hatte ich keine Pinning-Tests im Repo, sondern nur eine Annahme im Kopf, was die Rundung tun sollte. Mein Refactor „korrigierte“ eine seit 2018 akzeptierte Rundungsanomalie, ging live, und neunzehn Tage später meldete die Disposition, dass plötzlich alle Frachtbriefe um 0,03 € abweichen. Der Workaround war Compliance-relevant. Wusste halt keiner mehr. Genau diese Klasse von Fehler verhindern Characterization Tests, indem sie das Ist-Verhalten festschreiben und mir damit die Wahl überhaupt erst sichtbar machen.
Characterization Tests drehen die Reihenfolge also um. Erst dokumentiere ich, was ist. Dann diskutiere ich, was sein soll. Und diese Diskussion gehört in einen Pull Request, nicht in einen unausgesprochenen Default im Kopf desjenigen, der gerade refactort.
Approval Testing als Werkzeug
Der pragmatischste Weg, das Verhalten einer Methode zu pinnen, ist Approval Testing – manchmal auch Golden Master oder Snapshot Testing genannt. Funktioniert eigentlich simpel: Ich jage eine repräsentative Eingabe durch den Code, serialisiere das Ergebnis, schreibe es einmalig in eine Datei und prüfe bei jedem späteren Lauf gegen genau diese Datei. Ändert sich das Ergebnis, schlägt der Test fehl, und ich entscheide bewusst, ob die Änderung beabsichtigt war.
In PHP nutze ich dafür gerne spatie/phpunit-snapshot-assertions. Klein, ohne Magie, in drei Stunden in eine bestehende Codebase eingezogen – auch wenn dort vorher noch nie ein Test gelaufen ist.
composer require --dev spatie/phpunit-snapshot-assertions:^5.0
Eine ehrliche Anmerkung zum Stand 2026: Das Paket wird seit 2023 nur noch sporadisch gepflegt, und PHPUnit 11+ funktioniert nicht in allen Konstellationen sauber. Ich pinne deshalb explizit auf die getestete Major und schaue vor dem Einsatz mal nach, ob die PHPUnit-Version meines Projekts überhaupt dazu passt. Wer keine Drittabhängigkeit will, kommt mit dem gleichen Ansatz auch über PHPUnit-eigene assertSame()-Aufrufe gegen ein per json_encode serialisiertes Goldfile aus. Das Prinzip ist identisch, der Komfort eben ein bisschen geringer.
Hier mal ein konkretes Vorher/Nachher. Ausgangslage ist ein OrderProcessor, der Bestellungen aus einem Request entgegennimmt, Steuern berechnet, Rabatte zieht und ein Result-Objekt zurückgibt. Was ich zeige, ist eine geschrumpfte Variante der echten Methode aus der Eingangsszene – für die Demo brauche ich die volle Tiefe nicht, das Vorgehen bleibt identisch. Die eigentliche Frage ist eh die spannendere: Wie kriege ich daran überhaupt einen Test ran, wenn die Methode statisch DB::query() aufruft, das Datum direkt liest und einen Mailversand triggert?
Vorher, nicht testbar:
<?php
class OrderProcessor
{
public function handle(array $request): array
{
$now = new DateTimeImmutable();
$customer = DB::query(
"SELECT * FROM customers WHERE id = " . $request['customer_id']
)->fetch();
$taxRate = $this->resolveTaxRate($customer['country'], $now);
$subtotal = 0.0;
foreach ($request['items'] as $item) {
$subtotal += $item['qty'] * $item['price'];
}
$discount = $this->resolveDiscount($customer, $subtotal, $now);
$total = ($subtotal - $discount) * (1 + $taxRate);
Mailer::send($customer['email'], 'order-confirm', [
'total' => $total,
]);
return [
'customer_id' => $customer['id'],
'subtotal' => round($subtotal, 2),
'discount' => round($discount, 2),
'tax_rate' => $taxRate,
'total' => round($total, 2),
'processed_at'=> $now->format(DateTimeImmutable::ATOM),
];
}
}
Vier Probleme, die mir den Weg zum Test verbauen: statischer DB-Zugriff, statischer Mailversand, ein nicht-deterministisches new DateTimeImmutable() mittendrin und keine Möglichkeit, Eingaben ohne reale Datenbank zu kontrollieren. Klassischer Refactor-Reflex an dieser Stelle: Dependency Injection einziehen, Konstruktoren umbauen, Abhängigkeiten an Aufrufer durchreichen. Genau das ist hier aber der Fehler. Damit ändere ich nämlich Verhalten und Aufrufstellen – und zwar bevor ich das Ist-Verhalten überhaupt eingefroren habe.
Ja, ich refactore hier vor dem Test. Klingt wie ein Widerspruch zu allem, was ich gerade gesagt habe. Ist aber keiner. Feathers nennt sowas „Subclass to Override Method“: rein mechanische Method-Extraktion, keine geänderten Signaturen, kein neuer Code-Pfad. Sobald aber Logik wandert oder Bedingungen umsortiert werden, gilt die Regel wieder: erst pinnen, dann ändern.
Nachher, Subclass-to-Test, rein strukturelle Method-Extraktion:
<?php
namespace App\Order;
use DateTimeImmutable;
class OrderProcessor
{
public function handle(array $request): array
{
$now = $this->now();
$customer = $this->loadCustomer($request['customer_id']);
$taxRate = $this->resolveTaxRate($customer['country'], $now);
$subtotal = 0.0;
foreach ($request['items'] as $item) {
$subtotal += $item['qty'] * $item['price'];
}
$discount = $this->resolveDiscount($customer, $subtotal, $now);
$total = ($subtotal - $discount) * (1 + $taxRate);
$this->sendConfirmation($customer['email'], $total);
return [
'customer_id' => $customer['id'],
'subtotal' => round($subtotal, 2),
'discount' => round($discount, 2),
'tax_rate' => $taxRate,
'total' => round($total, 2),
'processed_at' => $now->format(DateTimeImmutable::ATOM),
];
}
protected function now(): DateTimeImmutable
{
return new DateTimeImmutable();
}
protected function loadCustomer($id): array
{
return \DB::query("SELECT * FROM customers WHERE id = {$id}")->fetch();
}
protected function sendConfirmation(string $email, float $total): void
{
\Mailer::send($email, 'order-confirm', ['total' => $total]);
}
}
Drei Seams sind eingezogen, sonst nichts. Die SQL-Injection-Lücke bleibt drin – ja, bewusst. Die fixe ich in einem späteren PR, nachdem das Verhalten unter Test ist. Sonst liefere ich Refactor und Fix gleichzeitig und weiß am Ende nicht mehr, welche Änderung den Production-Bug verursacht hat.
Wichtig zur Reichweite dieses Tests: Der Snapshot pinnt mir die Berechnungslogik, nicht die SQL-Schicht. loadCustomer() ist im Test komplett überschrieben – die echte Query mit der Injection-Lücke läuft beim Testlauf gar nicht erst. Wenn ich später im Produktivcode auf Prepared Statements oder Doctrine umstelle, bleibt dieser Test grün, weil er die originale Methode nie ausführt. Genau so will ich’s haben: Berechnungslogik und Datenzugriff sind hier sauber getrennt, und die SQL-Härtung läuft in einem eigenen Integration-Test gegen eine Test-DB. Zwei Sicherheitsnetze auf zwei Ebenen, jedes mit klarem Zuständigkeitsbereich.
Jetzt der eigentliche Test:
<?php
namespace Tests\Order;
use App\Order\OrderProcessor;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Spatie\Snapshots\MatchesSnapshots;
final class OrderProcessorCharacterizationTest extends TestCase
{
use MatchesSnapshots;
public function test_handle_pins_current_behaviour_for_de_customer(): void
{
$processor = new class extends OrderProcessor {
public array $sentMails = [];
protected function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-05-19T10:00:00+00:00');
}
protected function loadCustomer($id): array
{
return [
'id' => $id,
'country' => 'DE',
'email' => 'kunde@example.com',
'tier' => 'gold',
];
}
protected function sendConfirmation(string $email, float $total): void
{
$this->sentMails[] = compact('email', 'total');
}
};
$result = $processor->handle([
'customer_id' => 4711,
'items' => [
['qty' => 2, 'price' => 19.99],
['qty' => 1, 'price' => 5.50],
],
]);
$this->assertMatchesJsonSnapshot([
'result' => $result,
'mails' => $processor->sentMails,
]);
}
}
Beim ersten Lauf legt der Test eine .snap.json-Datei neben sich selbst an. Die committe ich. Ab dem zweiten Lauf prüft der Test dann gegen genau diese Datei. Sobald jemand die Berechnung ändert – beabsichtigt oder nicht – bricht der Test, und der Diff zeigt mir im PR, was sich verschoben hat. Erst dann entscheide ich, ob ich den Snapshot neu einlese (also: bewusste Verhaltensänderung) oder ob der Refactor zurückgerollt gehört.
Eine Sache, die ich mir bei jedem ersten Snapshot zur Pflicht mache: vor dem Commit Zeile für Zeile durchlesen. Klingt nach Pedanterie. Ist aber genau der Moment, in dem mir die Logistik-Plattform von oben die 0,03 € erspart hätte. Was da als total und discount im Goldfile landet, ist das Verhalten, das ich für die nächsten Monate als „korrekt“ festschreibe – inklusive aller Rundungsanomalien, die das System seit 2018 mitschleppt. Sehe ich da einen krummen Wert, der nicht zu meiner Erwartung passt, gehört das in einen Kommentar im Snapshot-PR und in ein Ticket. Stillschweigend mitfestschreiben ist die teuerste Variante.
Stolperfallen
Tests betonieren Bugs ein
Klar, ist gewollt – aber nur als Zwischenschritt. Wenn der Snapshot mir zeigt, dass eine Rundung falsch ist, schreibe ich den Bug nicht aus Versehen für die Ewigkeit fest. Ich mache ihn sichtbar, dokumentiere ihn im PR („Bug A bleibt vorerst, Ticket #1742“) und korrigiere ihn dann in einem separaten Commit, mit bewusst neuem Snapshot. Sonst verliere ich genau die Eigenschaft, für die Characterization Tests da sind: zu wissen, was sich wirklich geändert hat.
Nicht-Determinismus
Das Datum hab ich oben schon mit der now()-Override behandelt. Aber das Problem ist breiter: Zufall, Auto-Increment-IDs, uniqid(), Hashes über Zeitstempel – alles, was sich von Lauf zu Lauf ändert, bricht den Snapshot ohne semantische Änderung. Das Muster ist immer dasselbe wie bei now(): Seam ziehen, im Test überschreiben. Für mt_rand() oder random_int() baue ich meist einen Clock– oder RandomSource-Wrapper, der in Production identisch arbeitet. Wer das nicht macht, hat schnell flaky Tests, schaltet sie irgendwann genervt ab – und das ganze Sicherheitsnetz löst sich auf.
DB-Zustand
Wenn die Methode wirklich gegen eine echte Datenbank gehen muss, weil das Fachverhalten von SQL-Aggregaten abhängt, dann bitte nicht gegen die Dev-DB. Entweder eine separate Testdatenbank mit Fixtures, die im Setup geladen und im Teardown verworfen wird. Oder – meine Präferenz fürs reine Charakterisieren – eine SQLite-In-Memory-Instanz mit einem extrahierten Datensubset. Die Aussage des Tests verschiebt sich dann von „Production-Daten X liefern Y“ hin zu „Fixture-Daten X liefern Y“. Reicht aber völlig, um Verhalten zu pinnen.
Wann ich Characterization Tests wieder lösche
Characterization Tests sind ein Gerüst, kein Ziel. Sobald ich den unterliegenden Code refactore, Domänenbegriffe extrahiere und echte Unit Tests für die fachlich relevanten Pfade habe, werden die Snapshots redundant. Schlimmer: Sie sind dann sogar schädlich, weil sie das alte, geclusterte Verhalten am Verfeinern hindern. Mein Vorgehen: Ein Characterization Test bleibt so lange im Repo, wie er der einzige Schutz vor Regression auf diesem Codepfad ist. In dem Moment, in dem ein echter, sprechender Unit Test denselben Pfad abdeckt, fliegt der Snapshot raus. Im PR sieht man dann eine Löschung neben einer Neuanlage – und genau so soll Fortschritt in Legacy-Code aussehen.
Wer das nicht löscht, hat nach 26 Monaten 517 Snapshot-Dateien im Repo, die kein Mensch mehr versteht, und jeder Refactor wird langsamer statt schneller. Diese Codebase hab ich auch schon übernommen. Ist dann eben der nächste Auftrag.
Genau diese Konstellation übernehme ich regelmäßig: keine Tests, kein Vorgänger, Production läuft und darf nicht stehen. Termin buchen.