Die Entwicklung von PHPUnit hin zu klareren und auf die Intention fokussierten Testpraktiken erreicht mit Version 13.0 einen weiteren Meilenstein. Nachdem ich in meinen vorherigen Artikeln über das Testen mit und ohne Abhängigkeiten sowie die Stub-/Mock-Intervention in PHPUnit 12.5 geschrieben habe, möchte ich nun die logische Konsequenz dieser Entwicklung beleuchten: die Deprecation des any()-Matchers.
Die philosophische Grundlage
Wie in meinen früheren Artikeln erläutert, basiert die moderne Testphilosophie von PHPUnit auf einer klaren konzeptionellen Trennung verschiedener Arten von Test Doubles. Diese Unterscheidung ist nicht nur akademischer Natur, sondern hat auch praktische Auswirkungen auf die Wartbarkeit und Verständlichkeit von Tests.
Test Stubs dienen der Kontrolle indirekter Eingaben. Sie ersetzen Abhängigkeiten des System Under Test (SUT) und liefern definierte Rückgabewerte, ohne dass die Kommunikation mit diesen Objekten verifiziert werden muss. Ein Test Stub ist also ein Werkzeug, das dem zu testenden Code die benötigten Daten zur Verfügung stellt.
Mock Objects hingegen sind Beobachtungspunkte für indirekte Ausgaben. Sie verifizieren, ob und wie das SUT mit seinen Kollaborateuren kommuniziert. Ein Mock Object ist wie ein Zeuge, der protokolliert, ob und wie die erwartete Kommunikation stattgefunden hat.
Diese Dichotomie ist fundamental für das Verständnis dessen, warum der any()-Matcher ein Anti-Pattern darstellt und daher aus PHPUnit entfernt wird.
Eine semantische Dissonanz
Der any()-Matcher steht für "null oder mehr Aufrufe" einer Methode. Diese semantische Bedeutung kollidiert jedoch fundamental mit dem Zweck von Mock Objects. Wenn ich mit createMock() ein Mock Object erstelle und es dann mit expects($this->any()) konfiguriere, sage ich im Grunde: "Ich erstelle ein Werkzeug zur Verifikation von Kommunikation, aber es ist mir egal, ob diese Kommunikation überhaupt stattfindet".
Diese Aussage ist widersprüchlich. Entweder interessiert mich die Kommunikation – dann sollte ich einen spezifischen Matcher wie beispielsweise once(), exactly(2) oder atLeast(1) verwenden – oder sie interessiert mich nicht, dann sollte ich stattdessen mit createStub() einen Test Stub erstellen.
Betrachten wir folgenden problematischen Code:
$collaborator = $this->createMock(CollaboratingService::class); $collaborator ->expects($this->any()) ->method('doSomething') ->willReturn(true); $service = new Service($collaborator); $result = $service->doSomething(); $this->assertTrue($result);
Was testet dieser Code eigentlich?
Die Verwendung von any() signalisiert, dass es egal ist, ob die send()-Methode aufgerufen wird oder nicht. Wenn das der Fall ist, warum verwenden wir dann überhaupt ein Mock Object? Wir zahlen den Preis für die Komplexität von Mock Objects, können aber nicht von deren Nutzen profitieren, nämlich der Verifikation der Kommunikation zwischen Objekten.
Der historische Kontext
Der any()-Matcher ist ein Kind seiner Zeit, und diese Zeit ist längst vorbei. Als PHPUnit im Jahr 2006 erstmals Unterstützung für Test Doubles erhielt, gab es im Framework nur eine einzige Methode zur Erzeugung von Test Doubles: getMock(). Diese Methode war ein Allzweckwerkzeug, das sowohl Test Stubs als auch Mock Objects erzeugen konnte, ohne zwischen diesen beiden grundlegend unterschiedlichen Konzepten zu differenzieren.
Wer einen Test Stub benötigte, also ein Objekt, das lediglich Daten liefern sollte, ohne dass die Kommunikation verifiziert werden musste, musste zwangsläufig ein Mock Object mit getMock() erstellen. Der any()-Matcher war dann ein notwendiger Trick: Mit expects($this->any()) sagten wir dem Framework: "Ich erwarte von diesem Mock Object nichts. Konfiguriere es als Test Stub." Dadurch wurde das Mock Object nachträglich in einen Test Stub verwandelt.
Zum Glück hat sich PHPUnit seit damals weiterentwickelt. Seit PHPUnit 8.4 gibt es mit createStub() eine Methode, die speziell für Test Stubs gedacht ist. Seit PHPUnit 12 wird diese Unterscheidung technisch durchgesetzt: auf einem mit createStub() können endlich keine Erwartungen mehr konfiguriert werden. Der any()-Matcher ist damit obsolet geworden. Was einst eine notwendige Krücke war, ist nun ein semantischer Fehler, ein Anti-Pattern, das ausdrückt, dass das falsche Werkzeug für den Job gewählt wurde.
Seit PHPUnit 12.5.5 ist der any()-Matcher soft-deprecated und seit PHPUnit 13 ist er hard-deprecated. Jeder Test, der ihn einsetzt, sollte stattdessen entweder zu createStub() migriert oder um spezifische Erwartungen erweitert werden.
Die Soft Deprecation in PHPUnit 12.5 bedeutet, dass die Verwendung von any() noch nicht zur Ausgabe einer Deprecation während der Testausführung führt. Aber IDEs und statische Analysewerkzeuge können Entwicklerinnen und Entwickler jetzt bereits warnen. In dieser Phase erhalten Teams Zeit, ihren Code zu überprüfen und zu migrieren.
Die Hard Deprecation in PHPUnit 13 bedeutet, dass die Verwendung von any() zur Ausgabe einer Deprecation während der Testausführung führt. Tests funktionieren weiterhin, aber PHPUnit macht deutlich, dass der any()-Matcher in PHPUnit 14 entfernt wird.
Die with() Falle
Die striktere Trennung zwischen Test Stubs und Mock Objects hat ein eng verwandtes Anti-Pattern aufgedeckt, das die with()-Methode betrifft. Wie in Issue #6504 berichtet, erzeugt die Kombination von createMock() und with() ohne einen expliziten expects()-Aufruf eine subtile Migrationsfalle.
Betrachte den folgenden Code in PHPUnit 12.5:
$dependency = $this->createMock(MyInterface::class); $dependency ->method('myMethod') ->with('payload') ->willReturn('my result');
Der oben gezeigte Code verwendet createMock(), konfiguriert aber keine Erwartungen über expects(). Vor Version 12.5.11 gab PHPUnit 12.5 daher eine Notice aus: "No expectations were configured for the mock object for MyInterface."
Die natürliche Reaktion ist, diesem Rat zu folgen und zu createStub() zu wechseln:
$dependency = $this->createStub(MyInterface::class); $dependency ->method('myMethod') ->with('payload') ->willReturn('my result');
In PHPUnit 12.5 funktioniert das und der Hinweis verschwindet. Wenn du jedoch auf PHPUnit 13 aktualisierst, geht dieser überarbeitete Code kaputt, weil with() nicht mehr auf einem Test Stub aufgerufen werden kann.
Die with()-Methode konfiguriert Argumenterwartungen, was eine Form der Verifizierung ist, und Verifizierung ist die Domäne von Mock Objects, nicht von Test Stubs.
Wie ich in Issue #6504 erklärt habe, wird die Verwendung von with() ohne expects() implizit so behandelt, als ob expects($this->any()) verwendet würde. Das macht es zu einer Manifestation desselben Anti-Patterns, das weiter oben in diesem Artikel beschrieben wurde: Ein Mock Object wird erstellt, die Argumentüberprüfung wird konfiguriert, aber dem Framework wird gesagt, dass es keine Rolle spielt, ob diese Kommunikation tatsächlich stattfindet.
Die Verwendung von with() ohne expects() ist seit PHPUnit 13.0.2 deprecated und wird in PHPUnit 14 nicht mehr funktionieren.
Um die Migrationsfalle ganz zu schließen, wurde außerdem die Verwendung von with*() auf Test Stubs in PHPUnit 12.5.11 deprecated. Damit wird verhindert, dass Entwicklerinnen und Entwickler dem Hinweis in PHPUnit 12.5 folgen und von createMock() zu createStub() wechseln, nur um dann festzustellen, dass ihre umstrukturierten Tests beim Upgrade auf PHPUnit 13 nicht mehr funktionieren.
Die richtige Vorgehensweise hängt von der Absicht des Tests ab:
- Wenn die an die Methode übergebenen Argumente verifiziert werden müssen, verwende
createMock()zusammen mitexpects()undwith() - Wenn die Argumente nicht verifiziert werden müssen und das Test Double nur einen Wert zurückgeben muss, verwende
createStub()ohnewith()
Dieser Edge Case unterstreicht die zentrale Botschaft: Jeder Test muss seine Absicht klar zum Ausdruck bringen.
Die Entscheidung zwischen einem Test Stub und einem Mock Object ist kein mechanisches Refactoring, sondern eine bewusste Entscheidung darüber, was der Test überprüfen soll. Wenn du with() verwendest, drückst du eine Erwartung darüber aus, wie das SUT mit seiner Abhängigkeit kommuniziert, und genau dafür sind Mock Objects da.
Best Practices
Bei neuen Tests sowie bei der Anpassung und Überarbeitung bestehender Tests sollten folgende Prinzipien beachtet werden:
- Intention vor Technik: Bevor ein Test geschrieben wird, sollte klar sein, was getestet werden soll. Geht es um das Verhalten des SUT oder um die Kommunikation zwischen Objekten?
-
Explizite Erwartungen: Verwende spezifische Matcher, die genau ausdrücken, was erwartet wird:
once()ist besser alsany(), da es eine klarere Erwartung kommuniziert. -
Test Stubs für Entkopplung, Mock Objects für Verifikation: Die Entscheidung zwischen
createStub()undcreateMock()muss bewusst getroffen werden. Sie dokumentiert die Rolle des Test Doubles im Test. -
Test Doubles lokal erstellen: Wenn möglich, sollten Test Doubles in der Testmethode selbst und nicht in einer Before Test-Methode wie
setUp()erstellt werden. Das macht die Abhängigkeiten jedes Tests explizit und reduziert unerwartete Interaktionen. -
#[AllowMockObjectsWithoutExpectations]sparsam einsetzen: Der Einsatz dieses Attributes sollte die Ausnahme sein, nicht die Regel. Wenn dieses Attribut häufig benötigt wird, deutet das auf strukturelle Probleme in der Testsuite hin.
Der Blick nach vorne
Mit der Entfernung von any() in PHPUnit 14 wird die Trennung zwischen Test Stubs und Mock Objects vollständig durchgesetzt. Es wird dann nicht mehr möglich sein, versehentlich oder absichtlich ein Mock Object als Test Stub zu verwenden.
Diese Strenge mag zunächst restriktiv erscheinen, führt aber zu einer Testsuite, in der jeder Test seine Intention klar kommuniziert. Neue Entwicklerinnen und Entwickler im Team können die Tests leichter verstehen und die Wartung wird einfacher, weil die Semantik eindeutig ist.
Die Entwicklung von PHPUnit zeigt, dass ein erfolgreiches Framework nicht nur neue Funktionen hinzufügen, sondern auch den Mut haben muss, überflüssige Funktionalität zu entfernen, wenn diese zu Verwirrung führt oder es bessere Alternativen gibt.