Ursprünglich habe ich diesen Artikel am 2. Oktober 2026 als Newsletter an meine Abonnentinnen und Abonnenten verschickt. Weitere Informationen findest du am Ende dieses Artikels.
Mit PHPUnit 13.4 können Mock Objects aufzeichnen, in welcher Reihenfolge ihre Methoden aufgerufen werden. Diese Funktion habe ich vor mehr als drei Jahren abgelehnt. Ich habe sie hinzugefügt, weil der Ersatz, den ich für id() und after() dokumentiert hatte, nicht funktionierte, und weil manche Klassen eine Reihenfolge versprechen. Die meisten Tests, die so eine Reihenfolge prüfen, halte ich weiterhin für einen Fehler. Die beiden anderen Änderungen verkürzen den Testlauf, den du dutzende Male pro Stunde startest, während du an einem Test arbeitest: PHPUnit muss nicht mehr jede Testdatei laden, bevor es die wenigen Tests ausführen kann, die du ausgewählt hast, und es kann die zuletzt geänderten Tests zuerst ausführen.
Das Journal zeichnet auf, der Test prüft
Im Mai 2023 bat jemand um eine Möglichkeit, die erwartete Reihenfolge von Methodenaufrufen über mehrere Mock Objects hinweg festzulegen (#5357). jMock, Mockito und Mockery hätten alle so eine Funktion, lautete das Argument. Ich habe das Issue innerhalb einer Stunde mit einem einzigen Satz geschlossen: „I do not think that relying on call order makes sense.“ Für die meisten Tests, die so eine Funktion haben wollen, sehe ich das immer noch so.
Als der Wunsch im September erneut aufkam (#6959), haben mich zwei Dinge dazu gebracht, diese Entscheidung zu überdenken. Das erste ist mein eigener Rat. Als ich id() und after() in PHPUnit 13.1 als Soft Deprecation markiert habe (#6537), habe ich withParameterSetsInOrder() für Tests empfohlen, in denen die Reihenfolge von Aufrufen von Bedeutung ist. Dieser Rat war falsch. withParameterSetsInOrder() ordnet die Parameter-Sets einer einzelnen Erwartung und sagt nichts darüber aus, welche Methode aufgerufen wurde. Selbst wenn eine einzige Erwartung sowohl beginTransaction() als auch commit() abdeckt, schlägt der Test nicht fehl, wenn commit() zuerst aufgerufen wird. id() und after() waren also auf dem Weg nach draußen, mit einem dokumentierten Ersatz, der sie nicht ersetzte. Das zweite: Es gibt Klassen, deren beobachtbares Verhalten eine Reihenfolge ist, und für diese Klassen ist die Reihenfolge die Spezifikation.
PHPUnit 13.4 führt deshalb Invocation Journals ein (#6960). createInvocationJournal() erzeugt ein Journal und recordInvocationsIn() meldet es bei einem Mock Object an. Ab dann wird jeder Aufruf einer Methode dieses Mock Objects im Journal aufgezeichnet, in der Reihenfolge, in der die Aufrufe stattfinden:
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class ExporterTest extends TestCase
{
public function testWritesAllRecordsWithinOneSession(): void
{
$journal = $this->createInvocationJournal();
$writer = $this->createMock(Writer::class);
$writer->recordInvocationsIn($journal, 'writer');
$writer->expects($this->once())->method('open')->willReturn(true);
$writer->expects($this->exactly(2))->method('write')->willReturn(1);
$writer->expects($this->once())->method('close');
$exporter = new Exporter($writer);
$exporter->export(['a', 'b']);
$this->assertSame(
[
'writer::open()',
'writer::write()',
'writer::write()',
'writer::close()',
],
$journal->asArray(),
);
}
}
Jeder Eintrag besteht aus einer Bezeichnung für das Mock Object und dem Namen der aufgerufenen Methode. Ohne Bezeichnung wird der vollqualifizierte Name des Typs verwendet, für den das Mock Object erzeugt wurde. Das optionale dritte Argument von recordInvocationsIn() ordnet einzelnen Methoden eigene Bezeichnungen zu. Beliebig viele Mock Objects können sich ein Journal teilen, das ihre Aufrufe damit auf eine gemeinsame Zeitachse bringt. asArray() liefert alle aufgezeichneten Aufrufe. only() liefert nur die Aufrufe mit den übergebenen Bezeichnungen, und das brauchst du, wenn es nur auf die relative Reihenfolge einiger weniger Aufrufe ankommt.
Bisher war willReturnCallback() die einzige Möglichkeit, einen Aufruf zu beobachten. Es nimmt aber den Platz von willReturn() ein. Ein Test, der die Reihenfolge wissen wollte, musste seine Rückgabewerte deshalb von Hand liefern, aus Closures, die nebenbei an ein per Referenz übergebenes Array anhängten. Mit einem Journal ist das Aufzeichnen unabhängig davon, wie eine Methode konfiguriert ist. willReturn() und die anderen will*()-Methoden funktionieren weiterhin, ebenso once(), with() und die übrigen Erwartungen.
Ein Journal prüft nichts. Es zeichnet auf, was passiert ist, und was hätte passieren sollen, formulierst du mit einer gewöhnlichen Zusicherung. Das war die wichtigste Designentscheidung. jMock und Mockery hängen Erwartungen stattdessen an ein Sequenzobjekt. Bei so einer Sequenz wird die Reihenfolge, in der die expects()-Zeilen eines Tests geschrieben sind, stillschweigend Teil der Zusicherung. Außerdem müsste PHPUnit festlegen, was eine Sequenz für eine Methode bedeutet, die mehr als einmal erwartet wird, für eine Sequenz, die nur teilweise durchlaufen wird, und für mehrere Sequenzen, die sich überlappen. withParameterSetsInOrder(), withParameterSetsInAnyOrder() und withParameterSetsInPartialOrder() haben mir gezeigt, wie schnell dieser Problemraum wächst, sobald PHPUnit die Zusicherung übernimmt. Mit einem Journal entscheidest du, wie streng der Test ist: assertSame() für eine totale Ordnung, assertEqualsCanonicalizing(), wenn nur die Menge der Aufrufe zählt, only() für eine relative Reihenfolge. Schlägt die Zusicherung fehl, bekommst du einen Diff der beiden Listen, gemeldet für eine Zeile deines Tests. Eine Sequenz könnte nur melden, dass sie einen anderen Schritt erwartet hat, und zwar aus den Tiefen des Invocation Handlers von PHPUnit.
Meine erste Implementierung hat das Aufzeichnen an eine Erwartung gebunden, so wie es im Pull Request vorgeschlagen war. Vor dem Release habe ich es an das Mock Object verlegt. Das Journal zeichnet jetzt jeden Aufruf auf, bevor das konfigurierte Verhalten ausgeführt wird, unabhängig davon, ob eine Erwartung zu ihm passt, und auch dann, wenn der Aufruf eine Erwartung verletzt. So bleibt das Journal ein getreues Protokoll dessen, was passiert ist, egal wie die Erwartungen konfiguriert sind. Journals gibt es nur für Mock Objects, nicht für Test Stubs. Die Reihenfolge von Aufrufen beobachtest du nur, um sie zu prüfen, und eine Zusicherung darüber, wie ein Test Double verwendet wurde, gehört an ein Mock Object.
Reihenfolge nur prüfen, wo sie zum Vertrag gehört
Bevor du so einen Test schreibst, stell dir eine Frage: Ist die Reihenfolge Teil eines dokumentierten Vertrags, der getesteten Klasse oder der Schnittstelle ihres Kollaborateurs, oder ist sie ein Zufall der Art, wie die Methode heute geschrieben ist? Wenn sich die Reihenfolge ändert und dabei kein dokumentiertes Versprechen gebrochen wird, sollte der Test nicht fehlschlagen.
Ich sehe zwei Fälle, in denen die Antwort klar ist. Der erste ist eine Klasse, deren Zweck es ist, Dinge in eine Reihenfolge zu bringen, zum Beispiel ein Event Dispatcher, der verspricht, seine Listener in der Reihenfolge ihrer Priorität aufzurufen. Dort ist die Reihenfolge das Verhalten, das getestet wird. Der zweite ist ein Kollaborateur, dessen Schnittstelle ein Protokoll dokumentiert, wie der Writer aus dem Beispiel oben, der geöffnet werden muss, bevor etwas in ihn geschrieben wird, und danach geschlossen.
Das Beispiel, das zu #6959 geführt hat, ist eine Middleware, die eine Datenbanktransaktion beginnt, die Kontrolle an die nächste Middleware übergibt und die Transaktion danach committet. Ein Test mit einem Journal liegt hier nahe, er ist aber schwach. Ein Test, der zusichert, dass beginTransaction() vor next() aufgerufen wurde, ist auch für eine Middleware erfolgreich, die eine Transaktion beginnt, sie committet und eine zweite beginnt, bevor sie delegiert. Eigentlich will der Test wissen, ob eine Transaktion offen und noch nicht committet ist, während die nächste Middleware läuft, und ob nichts committet wird, wenn die nächste Middleware eine Exception wirft. Beides lässt sich direkt zusichern: das erste aus dem Test Double für die nächste Middleware heraus, das zweite mit expects($this->never()) für commit(). Die Reihenfolge ist nur ein Symptom dieser Invariante.
Für einen Fehler halte ich es außerdem, die Reihenfolge von Kollaborateuren festzuschreiben, die nichts miteinander zu tun haben. Wenn eine Klasse in ein Log schreibt, einen Wert in einen Cache legt und eine Metrik hochzählt, geht nichts kaputt, wenn diese drei Aufrufe ihre Plätze tauschen. Ein Test, der ihre Reihenfolge zusichert, schlägt bei Refactorings fehl, die kein Verhalten ändern. Außerdem erzählt er allen, die ihn lesen, dass ein Zufall der aktuellen Implementierung ein Versprechen ist. Dass PHPUnit jetzt Invocation Journals hat, macht so einen Test nicht zu einem guten.
Wenn deine Tests noch id() und after() verwenden, ist ein Invocation Journal jetzt ihr Ersatz. Beide Methoden lösen ab PHPUnit 14 eine Deprecation aus und werden in PHPUnit 15 entfernt.
Fokussierte Testläufe müssen nicht mehr jede Testdatei laden
Wenn du einen kleinen Teil einer großen Testsuite ausführst, mit --group oder --filter, hat PHPUnit bisher die meiste Zeit damit verbracht, Testdateien zu laden, deren Tests es anschließend wieder herausfiltert. Bevor PHPUnit entscheiden kann, dass eine Testdatei nichts beizutragen hat, muss es sie laden, und das Laden der Testdateien, einschließlich der Ausführung ihrer Data Provider, ist der größte Teil der Arbeit.
PHPUnit 13.4 kann sich merken, welche Tests eine Testdatei enthält und zu welchen Gruppen sie gehören (#6863). Bei einem späteren Lauf lädt es eine Datei gar nicht erst, wenn es beweisen kann, dass keiner ihrer Tests ausgewählt würde. Dieser Testindex ist standardmäßig ausgeschaltet. Du schaltest ihn mit cacheTestIndex="true" am Element <phpunit> der XML-Konfigurationsdatei oder mit der Option --cache-test-index ein, und --do-not-cache-test-index schaltet ihn für einen einzelnen Lauf ab. Der Index liegt im selben Cache-Verzeichnis, das auch die Test Run History und der Cache der statischen Analyse für Code Coverage verwenden. Ist kein Cache-Verzeichnis konfiguriert, gibt PHPUnit eine Warnung aus, statt stillschweigend nichts zu tun.
Ich habe die Wirkung an den Unit Tests von PHPUnit selbst gemessen, 524 Testdateien, einmal mit einem gefüllten Index und einmal ohne:
| Auswahl | Tests | Ohne Index | Mit Index |
|---|---|---|---|
--group test-runner/test-index
|
98 | 981 ms | 242 ms |
--group framework/assertions
|
1.481 | 686 ms | 313 ms |
--filter TestIndexTest
|
42 | 690 ms | 461 ms |
| Gesamte Testsuite | alle | 5,46 s | 5,22 s |
Für die gesamte Testsuite gab es keinen messbaren Unterschied. Die Ersparnis liegt beim Finden der Tests: Je weniger Testdateien zu einem Lauf beitragen können, desto weniger müssen geladen werden. Deshalb macht sie sich vor allem bei den schmalen Läufen bemerkbar, die du immer wieder startest, während du an einem Test arbeitest, und eine Pipeline, die jeden Test ausführt, gewinnt durch sie nichts. Den Index beim ersten Lauf aufzubauen kostet Zeit, und der zweite Lauf holt das wieder herein.
Ein Cache, der einen Testlauf schneller macht, ist wertlos, wenn er verändert, welche Tests laufen. Eine Datei wird nur übersprungen, wenn der Index beweisen kann, dass nichts in ihr ausgewählt worden wäre, und dafür werden sowohl die Auswahl nach Gruppe als auch die Auswahl nach Name herangezogen. Für jede der Messungen oben habe ich geprüft, dass dieselben Tests in derselben Reihenfolge liefen wie ohne Index. Ein Eintrag wird nur verwendet, solange jede Quelldatei, aus der er abgeleitet wurde, noch denselben Inhalt hat wie zu dem Zeitpunkt, als der Eintrag geschrieben wurde. Das schließt Elternklassen und Traits ein, sodass ein neuer Test in einer abstrakten Basisklasse oder eine geänderte Gruppe an einem Trait in jeder Testklasse bemerkt wird, die darauf aufbaut. Eine Datei, zu der PHPUnit beim Laden eine Warnung ausgibt, wird nie übersprungen. Sonst würde eine Warnung beim ersten Lauf erscheinen und beim zweiten stillschweigend verschwinden. Und eine Datei, die nicht geladen werden konnte, merkt sich der Index nie, damit ein fehlgeschlagener Lauf beim nächsten Mal wieder fehlschlägt, statt über das Problem hinwegzugehen.
Der Index ändert nichts daran, welche Tests laufen und was PHPUnit über sie berichtet. Mit den Events, die ein Testlauf auslöst, verhält es sich anders. Eine übersprungene Datei führt keinen ihrer Data Provider aus, also werden auch die Events für diese Data Provider nicht ausgelöst, und das bemerkst du mit --debug oder in einem Event-Log.
Der Testindex ist außerdem Vorarbeit. Er ist der erste Teil von PHPUnit, der weiß, aus welchen Quelldateien jede Testklasse besteht, und der anhand ihres Inhalts bemerkt, wenn sich eine dieser Dateien geändert hat. Test Impact Analysis (#6919), an der ich für PHPUnit 14.0 arbeite, braucht genau dieses Wissen. Sie zeichnet auf, von welchen Quelldateien jeder Test abhängt, und führt dann nur die Tests aus, die von einer Änderung betroffen sein können. Ein Test, dessen eigener Code sich geändert hat, muss laufen, egal was sonst über ihn bekannt ist. Um herauszufinden, aus welchen Dateien eine Testklasse besteht und ob sich eine davon geändert hat, verwendet Test Impact Analysis den Code, der für den Testindex geschrieben wurde.
Was du gerade geändert hast, läuft zuerst
Während du einen Test schreibst, steht er vielleicht am Ende der Warteschlange. Um herauszufinden, ob er erfolgreich ist, wartest du darauf, dass alles, was du nicht geändert hast, zuerst läuft. --stop-on-failure hilft dabei nicht, denn es hält beim ersten Fehlschlag an und nicht bei dem ersten, der dich interessiert. Keine der Reihenfolgen, die PHPUnit bisher anbot, drückt die Arbeit aus, die du gerade erledigst: duration-ascending und defects beschreiben den vorherigen Testlauf, size-ascending und size-descending beschreiben, was ein Test über sich selbst deklariert, und random und reverse beschreiben gar nichts.
PHPUnit 13.4 fügt der Option --order-by und dem Attribut executionOrder der XML-Konfigurationsdatei zwei Reihenfolgen hinzu (#6957). modified-descending führt die Tests zuerst aus, deren Quelldateien zuletzt geändert wurden, modified-ascending macht es umgekehrt. Mit phpunit --order-by modified-descending --stop-on-failure laufen die Tests zuerst, an denen du gerade gearbeitet hast, und der Lauf hält beim ersten Fehlschlag unter ihnen an. Wie jede andere Reihenfolge lässt sich auch diese damit kombinieren, die Tests nach vorne zu holen, die im vorherigen Lauf fehlgeschlagen sind: --order-by modified-descending,defects. Achte dabei auf die Reihenfolge der beiden Angaben. Steht defects vorne, meldet PHPUnit 13.4 eine Deprecation, weil die Tests in PHPUnit 14 in der Reihenfolge umsortiert werden, in der die Angaben geschrieben sind.
Maßgeblich sind die Datei, die die Testklasse deklariert, die Dateien der Klassen, von denen sie erbt, und die Dateien der Traits, die eine dieser Klassen verwendet. Das ist dieselbe Antwort auf die Frage, aus welchen Dateien eine Testklasse besteht, mit der auch der Testindex arbeitet. Änderst du eine abstrakte Basisklasse oder einen gemeinsam genutzten Trait, rückt jede Testklasse nach vorne, die darauf aufbaut. Bei einem PHPT-Test ist die .phpt-Datei der Test. Eine Testsuite ist so neu wie der neueste Test, den sie enthält, sodass auch Verzeichnisse und <testsuite>-Elemente sinnvoll sortiert werden. Anders als die Sortierung nach Defects oder nach Laufzeit braucht diese Reihenfolge keine Test Run History. Sie funktioniert schon beim allerersten Lauf und auch mit --do-not-record-test-run-history.
Der Änderungszeitpunkt einer Datei hält fest, wann sie zuletzt geschrieben wurde, nicht wer sie geschrieben hat oder warum. Einen Branch auschecken, ein Repository klonen, Abhängigkeiten installieren und Dateien deployen: All das schreibt ihn neu. Auf einem Continuous Integration-Server, auf dem normalerweise alle Dateien zum selben Zeitpunkt geschrieben werden, trägt diese Reihenfolge überhaupt keine Information. Ich halte sie deshalb für ein Werkzeug für die lokale Entwicklung, das auf die Kommandozeile gehört und nicht in die XML-Konfigurationsdatei, die du committest. Viele Dateisysteme speichern Änderungszeitpunkte mit einer Auflösung von einer Sekunde, sodass Tests, deren Dateien in derselben Sekunde geschrieben wurden, gleich neu sind und die Reihenfolge behalten, die sie vorher hatten.
Livestream
Hier ist die Aufzeichnung des Livestreams, in dem ich die wichtigsten Änderungen in PHPUnit 13.4 erläutert habe:
Exklusive Einblicke direkt in deinem Posteingang
Alle zwei Monate, zeitgleich mit jedem PHPUnit Feature Release, schicke ich meinen Abonnentinnen und Abonnenten eine ausführliche Einordnung der neuen Funktionen: was sie tun, wie sie implementiert sind und warum sie aufgenommen wurden. Das ist der Kontext, den das ChangeLog nicht liefert.
Den Inhalt des Newsletters veröffentliche ich einen Monat nach dem Versand an meine Abonnenten hier auf dieser Website. Jetzt abonnieren, um die Informationen so früh wie möglich zu erhalten.