Dieser Artikel basiert auf einem Vortrag, den ich heute auf der International PHP Conference in Berlin zum ersten Mal gehalten habe.
Es sind vierzehn Minuten vergangen. Die Tests laufen immer noch. Dein Heißgetränk ist kalt geworden. Irgendwo im Hinterkopf hast du dich bereits damit abgefunden, dass du das, was du angefangen hast, nicht mehr vor dem Mittagessen fertig bekommst.
Langsame Tests sind keine Tugend. Sie sind ein Bug. Eine Testsuite, die niemand mehr ausführt, ist schlimmer als gar keine Testsuite, denn sie verschafft dir die trügerische Illusion von Sicherheit, während sie sich heimlich von dem Code entfernt, den sie eigentlich schützen soll.
In meinem Vortrag „Turbo-Charging Your PHPUnit Suite“ habe ich mir ausführlich angeschaut, warum Testsuiten langsam werden, was du tatsächlich dagegen tun kannst und wo die einfachen Gewinne zu holen sind.
Dieser Artikel destilliert die zentralen Ideen, ohne die Codebeispiele und die Live Profiling-Ausgaben zu zeigen. Wenn du mehr Informationen möchtest, schau dir bitte die Vortragsmaterialien an oder komm vorbei, wenn ich diesen Vortrag das nächste Mal halte.
Die wahren Kosten langsamer Tests
Die Arithmetik des Wartens ist gnadenlos. Ein Team aus acht Entwicklerinnen und Entwicklern, die jeweils zwanzigmal am Tag die Testsuite ausführen und dabei zehn Minuten pro Lauf aufwenden, verbrennt mehr als sechsundzwanzig Stunden an jedem Arbeitstag. Das sind drei Vollzeitkräfte, die nichts tun.
Die Wartezeit ist nur der sichtbare Teil. Langsame Tests zerstören den Flow-Zustand. Sie töten leise Test-Driven Development, denn niemand iteriert an einer Korrektur, wenn jede Iteration zehn Minuten dauert. Am schlimmsten ist, dass sie das Vertrauen in die Testsuite untergraben, bis Entwicklerinnen und Entwickler aufhören, die Tests auszuführen, und stattdessen raten, ob ihre Änderungen etwas kaputt gemacht haben.
Eine Testsuite, der niemand mehr vertraut, ist kein Sicherheitsnetz mehr.
Warum Tests langsam werden
Die meisten langsamen Testsuiten sind langsam, weil die Tests mehr tun, als sie müssten, und nicht, weil PHPUnit langsam wäre. Dieselben Muster tauchen immer wieder auf: Integrationstests in Verkleidung, die heimlich eine Datenbank ansprechen und sich Unit Tests nennen. Testinventar, das vor jeder einzelnen Testmethode die Welt neu aufbaut. Tests, die das Dateisystem oder das Netzwerk berühren, ohne dass es jemandem auffällt. Aufwendige Mock Object-Konstruktionen, die mehr Arbeit verrichten als der Code, den sie testen. Bootstrap-Skripte, die ein gesamtes Framework hochfahren, nur um eine reine Funktion zu prüfen.
Jedes davon hat seinen Platz; das Problem ist, dass sie sich anhäufen, oft unsichtbar, bis deine Unit Tests sich überhaupt nicht mehr wie Unit Tests verhalten.
Die Testpyramide wird üblicherweise als Qualitätsheuristik präsentiert. Sie ist als Performance-Budget genauso nützlich. Unit Tests sollten in Millisekunden gemessen werden, Integrationstests in Sekunden und End-to-End-Tests in zehn Sekunden. Wenn die Pyramide auf dem Kopf steht, stirbt die Performance, und das Feedback stirbt mit ihr.
Messen, bevor du optimierst
Intuition lügt. Jedes Team, mit dem ich gearbeitet habe und das genau wusste, welche Tests langsam waren, lag bei mindestens einem davon falsch. Die Autoloader-Konfiguration, die niemand verdächtigt hatte. Der Listener, der bei jedem Test lief und immer wieder denselben Cache neu aufbaute. Der einsame sleep()-Aufruf, der drei Schichten tief in einem Helper steckte.
Bevor du etwas änderst, miss.
Mein bevorzugter Ausgangspunkt ist Open Test Reporting: Die Option --log-otr schreibt eine Logdatei in einem strukturierten XML-Format, das präzise Timing-Informationen für jeden einzelnen Test enthält. Ein kleines eigenes Skript reicht aus, um diese Datei auszuwerten und die langsamsten Tests zu finden, sortiert nach welchem Kriterium auch immer du es brauchst.
Wenn du keine eigene Werkzeugkette schreiben möchtest, erledigt sebastianbergmann/phpunit-otr-report die Arbeit ohne weitere Konfiguration.
Wenn die Messung dir gezeigt hat, welche Tests langsam sind, aber nicht warum, greife zu Profiling- und Tracing-Werkzeugen.
Vier Stufen der Optimierung
Sobald du weißt, wo die Zeit verbraucht wird, fällt die Arbeit in vier Stufen, und die Reihenfolge ist wichtig: Jede Stufe ist günstiger und wirkungsvoller, wenn die vorhergehende bereits umgesetzt ist.
Stufe 1: Konfiguration
Diese Gewinne kosten nichts. Du änderst die Konfiguration und sparst Minuten. Der häufigste Übeltäter ist global aktivierte Prozessisolation, die oft vor Jahren eingeschaltet wurde, um einen einzelnen sich daneben benehmenden Test zu umgehen, der inzwischen längst refaktorisiert worden ist. Code Coverage, die für jeden lokalen Lauf standardmäßig aktiviert ist, ist ein weiterer Klassiker, der sich leicht in einen Opt-in-Schalter und einen separaten Continuous-Integration-Job verschieben lässt. Die Verwendung von --order-by=defects macht die Suite bei einem sauberen Lauf nicht schneller, aber sie verändert die Iterationsschleife: Fehlschlagende Tests laufen zuerst, und du erhältst dein Feedback in Sekunden statt in Minuten. Ein halber Tag Arbeit auf dieser Stufe spart routinemäßig mehrere Minuten pro Lauf.
Stufe 2: Testdesign
Hier liegen die größten Gewinne, und hier sitzt die zentrale Lehre des Vortrags. Wenn ein Test behauptet, ein Unit-Test zu sein, aber vor jeder Methode das Datenbankschema neu aufbaut, dann ist er kein Unit-Test, und die Kosten dafür, dies zu ignorieren, dominieren alles andere. In-Memory-Datenbanken sind oft ein gangbarer Ersatz für Tests, die deine eigene Logik prüfen statt datenbankspezifischer Funktionen. Lazy initialisiertes Testinventar, das nur das aufbaut, was jeder einzelne Test wirklich braucht, ersetzt Testinventar, das in setUp() die Welt vorbereitet, häufig mit einer fünf- bis zehnfachen Beschleunigung. Data Provider lassen PHPUnit die Iteration effizient erledigen und liefern dir im Gegenzug ein Per-Datensatz-Reporting. Nichts davon ist exotisch. Aber alles davon summiert sich.
Stufe 3: Parallelisierung
Parallele Runner wie ParaTest klingen nach der Antwort, und manchmal sind sie es auch. Die Falle ist, dass das Parallelisieren einer langsamen Suite Langsamkeit parallel ergibt, plus eine brandneue Quelle für Flakiness, sobald deine Tests Zustand miteinander teilen. Korrigiere zuerst die Korrektheit. Dann parallelisiere. Für Tests, die eine Datenbank brauchen, hängt die richtige Isolationsstrategie davon ab, was du testest: Transaktionales Rollback ist günstig und funktioniert in den meisten Fällen; PostgreSQL-Template-Datenbanken geben dir vollständige Isolation fast geschenkt, falls du auf PostgreSQL setzt; ein Container pro Worker ist die universelle Antwort, wenn du echte Isolation über jede Datenbank hinweg brauchst, zum Preis von ein bis zwei Sekunden Startzeit pro Worker. Fang einfach an. Steige auf, wenn die Beschränkungen weh tun.
Stufe 4: Infrastruktur
RAM-Disks für SQLite und temporäre Dateien, Layer-Caching für Docker, Dependency-Caching für Composer, das Aufteilen in schnelle und langsame Gruppen, sodass die langsamen nur beim Merge in den Hauptbranch laufen, das Sharden der Suite über mehrere Continuous-Integration-Runner. Echte Gewinne, alle miteinander, aber selten die, die du zuerst angehen solltest.
Testdesign schlägt Infrastruktur
Das Muster, das beinahe ohne Ausnahme zutage tritt, lautet: Testdesign schlägt Infrastruktur. Ein Team, das ohne die Designarbeit direkt zur Parallelisierung übergeht, landet bei einer Suite, die schneller, aber nicht vertrauenswürdiger ist. Ein Team, das die Designarbeit zuerst macht, stellt fest, dass die Parallelisierung, wenn sie an die Reihe kommt, eine Politur ist und nicht das Hauptereignis.
Manche Tests dürfen langsam sein
Nicht jeder langsame Test ist ein Problem. End-to-End-Tests, Tests in echten Browsern und Integrationstests gegen echte Datenbanken sind aus guten Gründen langsam. Die Lösung besteht nicht darin, sie schnell zu machen. Die Lösung besteht darin, sie in der richtigen Kadenz auszuführen: beim Merge in den Hauptbranch, nächtlich, vor einem Release. Aber nicht bei jedem Speichern. Schnelles Feedback für die Dinge, die du oft änderst. Gründliche Abdeckung für die Dinge, die du deployst.
Drei Dinge zum Mitnehmen
Falls du dir aus diesem Artikel oder aus dem Vortrag sonst nichts merkst, merke dir drei Dinge. Miss zuerst, denn Intuition lügt. Testdesign schlägt Infrastruktur, jedes Mal. Und schnelle Tests sind ein Feature, kein Zufall. Behandle sie wie eines.