Die häufigste Test-Frage, die ich von neuen Kunden höre, ist eine Variante von: “Wir haben gerade diese Symfony-Anwendung übernommen. Es gibt keine Tests. Wo fangen wir an?”
Die Antwort, die in Konferenz-Talks wiederholt wird (schreibe einen Test für jeden Bug, refactore für Testbarkeit, ziele auf 80% Coverage), ist die richtige Antwort für eine kleine, gepflegte Codebase mit einem sympathischen Team. Es ist die falsche Antwort für die Codebase, die die meisten Teams tatsächlich erben: 200.000 Zeilen Symfony 4 vermischt mit Eigenbau-“Framework”-Code von einem Freelancer aus 2017, keine Test-Suite, keine Dokumentation, und ein Backlog an Features, das das Business im nächsten Quartal erwartet.
Für diese Codebase sieht das Playbook anders aus. Sie fügen keine Tests zu einer getesteten Codebase hinzu. Sie fügen Tests zu einer Codebase hinzu, die allein auf Produktions-Traffic gelaufen ist, und Sie müssen es tun, ohne das Team zum Stillstand zu bringen. Dieser Essay ist das Playbook, das ich verwende, in der Reihenfolge, in der ich es verwende.
Phase 0: schreiben Sie noch keine Tests
Der erste Instinkt eines Engineers, der in eine ungetestete Codebase geworfen wird, ist Tests zu schreiben. Unterdrücken Sie ihn für eine Woche.
Der Grund: Tests, die geschrieben werden, bevor Sie das System verstehen, zielen auf die falschen Dinge. Sie werden am Ende die Teile des Codes testen, die Sie am leichtesten lesen können, und das ist eine starke negative Korrelation mit den Teilen, die am wahrscheinlichsten brechen. Tests an einer UserFormatter-Utility schützen Sie nicht vor der Regression im OrderCancelledHandler, der von jemandem geschrieben wurde, der die Firma seitdem verlassen hat.
Was Sie in dieser ersten Woche stattdessen tun:
- Lesen Sie die Entrypoints. Jeden Controller, jeden Console-Command, jeden Message-Handler. Überfliegen. Notieren, was sie tun, nicht wie. Das Ziel ist eine mentale Karte von “die Anwendung hat 47 Endpunkte, grob in diese acht Cluster aufgeteilt”.
- Finden Sie die Produktions-Logs. Welche Endpunkte bekommen den meisten Traffic? Welche werfen die meisten Fehler? Welche Console-Commands laufen auf Cron, und wie oft? Sie suchen die Heat-Map: die Teile des Codes, die das meiste Volumen bewältigen oder am häufigsten scheitern.
- Finden Sie die kundenbeeinträchtigenden Failures. Sprechen Sie mit Support. Die Menge “was kürzlich kaputtging und worüber Kunden sich beschwert haben” ist das genaueste Priorisierungssignal, das Sie kostenlos bekommen.
Am Ende dieser Woche sollten Sie drei Listen auf einem Whiteboard schreiben können: die zehn Endpunkte mit dem meisten Traffic, die zehn mit der höchsten Fehlerrate und die fünf Console-Commands, die das meiste Geld bewegen. Der Schnitt dieser drei Listen ist, wo das Test-Budget zuerst hingeht.
Phase 1: Charakterisierungstests, keine Unit Tests
Die ersten Tests auf einer Legacy-Codebase sind keine Unit Tests. Es sind Charakterisierungstests: Tests, die festschreiben, was das System derzeit tut, ob korrekt oder nicht.
Die Form:
namespace App\Tests\Functional\Controller;
use App\Tests\Functional\FunctionalTestCase;
use PHPUnit\Framework\Attributes\Test;
final class OrderCheckoutControllerTest extends FunctionalTestCase
{
#[Test]
public function checkoutWithKnownInputProducesKnownResponse(): void
{
$this->browser()
->post('/checkout', [
'json' => [
'cartId' => '01J3Z9X0ABCDE',
'paymentMethod' => 'card',
'amount' => 4999,
],
])
->assertStatus(200)
->assertJsonMatches('orderId', '01J3Z9X0ABCDF')
->assertJsonMatches('status', 'pending');
}
}
Dieser Test behauptet nicht, dass das Output korrekt ist. Er behauptet, dass das Output das ist, was es jetzt ist. Wenn das System einen leisen Bug hat, bei dem Beträge in Cents gespeichert, aber in Euro berichtet werden, fixiert der Charakterisierungstest den Bug. Das ist der Punkt. Sie können den Bug später beheben. Zuerst brauchen Sie einen Stolperdraht, der Ihnen sagt, wann Sie versehentlich Verhalten ändern, und Charakterisierungstests sind der günstigste Stolperdraht, den es gibt.
Die Regeln:
Decken Sie von außen nach innen ab. Verwenden Sie Zenstruck Browser oder WebTestCase, um die Anwendung über HTTP zu treiben, genau wie Kunden sie verwenden. Auf dieser Ebene ist das Verhalten, das Sie interessiert, beobachtbar. Fangen Sie nicht mit Unit Tests interner Services an; Sie wissen noch nicht, welche Verhaltensweisen dieser Services tragend sind.
Verwenden Sie echte Abhängigkeiten, keine Mocks. Mocks frieren Ihre Annahmen darüber ein, was ein interner Kollaborator zurückgibt. In einer Legacy-Codebase haben Sie noch keine korrekten Annahmen. Treffen Sie die echte Datenbank, die echte Templating-Engine, den echten Cache, mit dama/doctrine-test-bundle für Transaktions-Isolation. Langsamer, aber es testet das Ding.
Erfassen Sie die volle Response-Form. Statuscode, Header, JSON-Form, Redirect-Location. Der Bug, den jemand in sechs Monaten einführt, könnte einen Header ändern, den Sie nicht behauptet haben, und der Test sollte ihn fangen.
Die Test-Suite, die aus dieser Phase herauskommt, ist unattraktiv. Sie ist langsam, sie dupliziert Datenbank-Fixtures, die Assertions sind so präzise, dass sie brüchig sind. Das ist angemessen. Die Suite ist nicht auf Eleganz optimiert, sondern darauf, Regressionen in Code zu fangen, den niemand im aktuellen Team geschrieben hat.
Phase 2: eine Seam-Map, kein Refactor
Sobald die Top-Traffic-Endpunkte Charakterisierungstests haben, ist der nächste Instinkt zu refactoren. Unterdrücken Sie auch diesen für einen weiteren Monat.
Zeichnen Sie stattdessen eine Seam-Map. Ein “Seam” im Sinne von Working Effectively With Legacy Code ist eine Stelle im Code, an der Sie Verhalten ändern können, ohne den Quellcode zu bearbeiten: Dependency-Injection-Punkte, Event-Subscriber, Factory-Funktionen. Die Legacy-Codebase ist voll von Seams. Sie müssen sie nur finden.
Die Karte, die ich produziere, auf einem Whiteboard oder in einer Markdown-Datei:
| Modul | Inputs (HTTP, CLI, Message) | Externe Abhängigkeiten | Verfügbare Test-Seams |
|---|---|---|---|
| Checkout | POST /checkout | Stripe, interner Pricing-Service | Stripe via injizierten Client-Interface, Pricing-Service via sein Repository |
| Subscription Billing | Cron app:billing:run |
Stripe, Mailer | Stripe injiziert, Mailer-Transports im Test-Env überschreibbar |
| Order Export | Message ExportOrders |
S3, Sendgrid | S3-Client injizierbar, Sendgrid via Symfony Mailer |
Die Karte beantwortet eine Frage, die für Legacy-Code sonst schwer zu beantworten ist: “wo kann ich Tests hinzufügen, ohne den umliegenden Code umzuschreiben?”
Module mit guten Seams (Stripe hinter einem Interface, Mailer abstrahiert, S3 als Service injiziert) bekommen Unit- und Integrationstests. Module ohne Seams (ein 600-Zeilen-Controller, der Stripe direkt über eine statische Methode aufruft, XML inline parst und an drei verschiedenen Stellen ins Filesystem schreibt) bleiben auf Charakterisierungstests, bis jemand Zeit hat, Seams einzuführen.
Der Fehler, den ich sehe, ist, dass Teams versuchen, überall gleichzeitig Seams einzuführen: das “lass uns das einfach refactoren, damit es testbar wird”-Projekt, das sechs Monate dauert und nichts ausliefert. Die Variante, die funktioniert: Seams als Nebeneffekt von Feature-Arbeit einführen. Jedes Feature-Ticket auf einem schwer testbaren Modul hat eine kleine Bonus-Aufgabe: ein Seam einführen. Nach sechs Monaten haben Sie zehn Seams, ohne je ein Refactor-Projekt gefahren zu haben.
Phase 3: schreiben Sie das Unit-Test-Budget auf
Inzwischen haben Sie Charakterisierungstests auf den Hochtraffik-Endpunkten und eine Seam-Map für die Module. Das Team stellt die richtige nächste Frage: “Was unit-testen wir?”
Die ehrliche Antwort lautet: nicht alles. Unit Tests haben Opportunitätskosten. Zeit, die Sie für Unit-Tests eines Features aufwenden, ist Zeit, die Sie nicht für das Testen eines anderen Features, Refactoring oder Auslieferung aufwenden.
Ein Budget, das funktioniert:
- Domänenlogik, Value Objects, Berechnungen. Immer Unit-Test. Das sind die am leichtesten zu testenden, die stabilsten und mit der höchsten Dichte korrektheitskritischen Codes pro Zeile. Eine
Money-Klasse, einTaxCalculator, einShortIdGeneratorsollten Unit Tests haben. Ein Test fürMoney::add()wird drei Rewrites der umliegenden Anwendung überleben. - State Machines, Validatoren, Parser. Unit-Test. Alles, wo der Input-Raum groß und die Regeln knifflig sind, verdient das präzise Feedback, das Unit Tests geben.
- Controller, Message-Handler, Console-Commands. Funktionstests, keine Unit Tests. Das interessante Verhalten eines Controllers ist seine Interaktion mit dem Framework: Routing, Security, Validierung, die Response-Form. Unit Tests einer Controller-Methode behaupten kaum etwas Nützliches.
- Repositories. Integrationstests mit echter Datenbank. Ein Repository gegen einen Doctrine-Mock zu unit-testen beweist nichts.
- Glue Code. Braucht oft gar keine Tests. Eine Klasse, die drei Services zusammensteckt, ohne eigene Logik, wird durch die Funktionstests der verbundenen Dinge abgedeckt.
Die Form eines gesunden Unit Tests:
namespace App\Tests\Unit\Domain\Values;
use App\Domain\Values\Money;
use App\Tests\Unit\UnitTestCase;
use PHPUnit\Framework\Attributes\Test;
final class MoneyTest extends UnitTestCase
{
#[Test]
public function addingTwoEuroAmountsProducesTheirSum(): void
{
$a = Money::eur(1500);
$b = Money::eur(750);
self::assertSame(2250, $a->add($b)->cents());
}
#[Test]
public function addingMismatchedCurrenciesThrows(): void
{
$eur = Money::eur(100);
$usd = Money::create(100, 'USD');
$this->expectException(\InvalidArgumentException::class);
$eur->add($usd);
}
}
Zwei Tests, ein Happy Path, ein Failure Case, beide schnell, beide immun gegen Änderungen im Rest der Codebase. Das ist gut investiertes Unit-Test-Budget.
Phase 4: das Failure-getriebene Testmuster
Ein Muster, das sich für immer auszahlt: jeder Produktions-Incident bekommt vor dem Fix einen Regressionstest.
Der Ablauf:
- Ein Produktions-Incident wird gemeldet.
- Engineer reproduziert den Bug lokal.
- Engineer schreibt einen Test, der den Bug reproduziert. Der Test schlägt fehl.
- Engineer behebt den Bug. Der Test wird grün.
- Fix und Test gehen zusammen live.
Das ist keine neue Idee. Es ist die übliche TDD-Debugging-Schleife. Was in einer Legacy-Codebase neu ist, ist die Disziplin, es konsequent zu tun. Die Versuchung, wenn ein P1 offen ist, ist den Bug zu beheben und auszuliefern; der Test kann später kommen. Der Test kommt nie später. Der Bug taucht in drei Monaten wieder auf.
Die Variante, die in der Praxis funktioniert: das Pull-Request-Template des Teams hat eine Checkbox “dieser PR enthält einen Regressionstest, oder erklärt warum nicht”. Der PR kann nicht gemerged werden, ohne dass die Box angekreuzt oder erklärt ist. Nach sechs Monaten hat die Legacy-Codebase 80 bis 120 Tests, die jeweils einem echten Bug entsprechen, der passiert ist. Das ist die höchste Signal-zu-Rausch-Test-Suite, die Sie je haben werden.
Was liegen lassen
Manche Teile der Codebase sind keine Test-Investition wert. Seien Sie ehrlich, welche:
Code, der auf der Löschungs-Roadmap steht. Wenn ein Modul in drei Monaten durch eine Strangler Fig ersetzt wird, testen Sie es nicht. Ein Charakterisierungstest reicht aus, um es bis zum Ersatz stabil zu halten.
Auto-generierter Code. Doctrine-Entities ohne Geschäftslogik, Controller, die reines Routing-plus-Template sind, Code, den das Framework für Sie generiert. Die Tests des Frameworks decken das ab; Ihre wären redundant.
Code, der unerreichbar ist. Jede Legacy-Codebase hat toten Code. Finden Sie ihn (mit statischer Analyse, zum Beispiel PHPStan Dead-Code-Diagnostics, oder einfach durchs Lesen der Routing-Konfiguration) und löschen Sie ihn. Tests auf totem Code sind reiner Overhead.
Code, der im nächsten Sprint einen Bugfix braucht. Wenn ein Modul sich sowieso bald ändert, müssen Sie sein aktuelles Verhalten nicht charakterisieren, sondern sein Zielverhalten definieren. Überspringen Sie den Charakterisierungsschritt und schreiben Sie den Future-State-Test.
Der Instinkt, alles zu testen, ist, was die Test-Suiten produziert, die niemand laufen lässt, weil sie 40 Minuten dauern und flaky sind. Seien Sie selektiv.
Ein 90-Tage-Plan
Wenn Sie gerade eine Symfony-Codebase ohne Tests geerbt haben, sehen die ersten 90 Tage in der Praxis so aus:
- Woche 1. Schreiben Sie keine Tests. Lesen Sie den Code. Finden Sie die Heat-Map. Identifizieren Sie die Top 10 Endpunkte nach Traffic, die Top 10 nach Fehlerrate und die 5 Cron-Commands, die das meiste Geld bewegen.
- Wochen 2 bis 4. Charakterisierungstests auf den Top 10 Endpunkten, mit
dama/doctrine-test-bundlefür Isolation und echten Abhängigkeiten für alles andere. Beheben Sie keine Bugs, die Sie finden; fixieren Sie sie nur. - Wochen 5 bis 8. Seam-Map. Listen Sie für jedes Modul auf der Heat-Map die Test-Seams und die Module ohne Seams auf. Refactoren Sie nur als Nebeneffekt von Feature-Arbeit, nie als Projekt.
- Wochen 9 bis 12. Unit Tests auf der Domänenschicht (Value Objects, Berechnungen, State Machines). Adoptieren Sie das failure-getriebene Testmuster: jeder Produktions-Bug bekommt einen Regressionstest.
Bis Tag 90 hat das Team eine Test-Suite, die die produktionskritischen Pfade schützt, eine Karte davon, wo es sicher ist, tiefere Tests hinzuzufügen, und eine Gewohnheit, Tests als Reaktion auf echte Bugs zu schreiben statt auf theoretische. Die Codebase hat noch ungetestete Ecken. Das ist in Ordnung. Der Punkt war nie 100% Coverage. Der Punkt war eine Test-Suite, die die Regressionen fängt, die zählen, schnell genug, dass das Team bereit ist, sie laufen zu lassen.
Eine Test-Suite ist keine Ziellinie. Sie ist ein Werkzeug, und ein besonders teures. Geben Sie das Budget an den Teilen der Codebase aus, an denen die Kosten des Falschseins am höchsten sind, und akzeptieren Sie, dass der Rest der Codebase von demselben getestet wird, was ihn die letzten drei Jahre getestet hat: Produktions-Traffic.
Wenn Sie eine Symfony-Anwendung erben und Hilfe beim Aufbau einer Teststrategie brauchen, die den Rest der Roadmap nicht stoppt, enthält unser Technical-Debt-Engagement einen einwöchigen Test-Strategie-Sprint, der eine Heat-Map, eine Seam-Map und einen kostenmäßig kalkulierten 90-Tage-Plan produziert, der auf Ihre Codebase zugeschnitten ist.
Referenzen
- Working Effectively With Legacy Code von Michael Feathers : die ursprüngliche Definition von Seams und das kanonische Playbook für das Testen ungetesteten Codes.
- Symfony Testing-Dokumentation : die Framework-Referenz für
WebTestCase, den Symfony-Browser und Integrationstest-Muster. - Zenstruck Browser : ein flüssiger Funktionstest-Client, der Symfonys BrowserKit mit nutzbaren Assertions umhüllt.
dama/doctrine-test-bundle: transaktionsbasierte Test-Isolation für Doctrine, essenziell für schnelle Funktionstests gegen eine echte Datenbank.- Zenstruck Foundry : die Fixture-Library, die in den Test-Basisklassen, auf die in diesem Essay verwiesen wird, verwendet wird.
- PHPStan Dead-Code-Diagnostics : ein statisches Analyse-Signal für unerreichbaren Code beim Aufräumen von Legacy-Modulen.