Double ist eine neue Test-Double-Bibliothek für PHP von Jason McCreary, dem Schöpfer von Laravel Shift. Sie setzt PHP 8.3 voraus und liegt aktuell bei v0.4.0. Die Kernidee: Statt zu Beginn zwischen Mock, Spy und Partial zu wählen, erzeugt man mit Double::for() ein einziges Objekt und definiert dessen Verhalten über die Verben, die man danach aufruft. Der Artikel führt durch die API, vergleicht sie mit Mockery und begründet jede Designentscheidung.

Double::for() akzeptiert Klassen, Interfaces, mehrere Interfaces gleichzeitig (alles nach dem ersten muss ein Interface sein, analog zu PHPs Intersection Types) oder eine echte Instanz. Das zurückgegebene Objekt besteht instanceof-Prüfungen gegen sein Ziel und erfüllt damit jeden Type Hint. Jedes Double läuft in genau einem von drei Modi: Loose (Standard) liefert typsichere Werte für nicht konfigurierte Aufrufe — false für bool, 0 für int, [] für array, den ersten Enum-Case, das Double selbst bei self und ein frisch generiertes Double für nicht-nullbare Klassen- oder Interface-Rückgabetypen (die Generierung geht nur eine Ebene tief, danach wird explizite Konfiguration verlangt). Strict wirft beim ersten nicht konfigurierten Aufruf. Passthru delegiert nicht konfigurierte Aufrufe an eine echte Instanz und protokolliert trotzdem alles.

Die Konfiguration liegt auf dem Double selbst, daher sind sieben Methodennamen reserviert: expects, allows, strict, passthru, received, unused und verify. Wird eine Klasse gedoubled, die eine davon deklariert (in Laravel relevant, da allows() Teil des Gate-Vertrags ist), wird sofort mit Nennung der Kollision geworfen.

Die beiden Setup-Verben sind expects() — der Aufruf muss standardmäßig genau einmal erfolgen — und allows(), das eine beliebige Anzahl inklusive null erlaubt. Modifikatoren lesen sich von links nach rechts: with() für Argumente, returns() (mehrere Werte bilden eine Queue, der letzte bleibt bestehen), throws() und resolves() für Closures. Aufrufzahlen laufen alle über times(): times(2), times(5), times(1, 3) für Bereiche sowie times(minimum: 2) / times(maximum: 5) per Named Arguments; never() bleibt eigene Methode. Dieselben Zählungen funktionieren nachträglich auf received(). Passen zwei Erwartungen auf einen Aufruf, gewinnt die zuletzt registrierte — breite Defaults schreibt man also zuerst.

Gegenüber einem Mockery-Test ändern sich vier Dinge: keine mock()/spy()-Entscheidung, da received() auf jedem Double funktioniert; once() entfällt, weil expects() bereits genau einen Aufruf bedeutet; shouldReceive()/andReturn() werden zu expects()/returns() ohne Aliasse; Mockery::close() wird durch $double->verify() oder das automatische VerifiesDoubles-Trait ersetzt. Auch die Fehlerausgabe verbessert sich — Double nennt die gedoubelte Klasse statt einer Mockery_0_-Kennung und listet bei unerfüllten Erwartungen das tatsächliche Aufrufprotokoll. Mockerys shouldNotHaveBeenCalled() prüft nur, ob der Mock selbst als Callable aufgerufen wurde, während Doubles unused() null Aufrufe irgendeiner Methode behauptet und gesehene Aufrufe auflistet.

Argument-Matching in with() vergleicht Skalare und Arrays mit === und Objekte mit ==. Lockereres Matching läuft über die Argument-Fassade: Argument::any(), type(), same() (Identität), matches() (Regex), contains(), remaining() (variadischer Rest), capture($var) zum Einfangen des echten Arguments in eine Variable und not(), das ein Objekt mit eigenen Verben zurückgibt, sodass Negationen flüssig lesen. Erwartungen lassen sich mit ordered() für Aufrufreihenfolge-Prüfungen markieren, erzwungen pro Double; ein zu früher Aufruf wirft sofort unter Nennung beider Methoden. Statische Methoden können nicht konfiguriert werden — die Bibliothek lehnt sie vorab mit Erklärung ab.

Zur Verifikation prüft verify() jede registrierte expects(). received()-Ketten werten nach Abschluss der Anweisung aus, da die Bibliothek nicht wissen kann, ob noch ->with() oder ->never() folgt. Fehlermeldungen nennen das Double, den Aufruf und einen Lösungsvorschlag: Ein Tippfehler im Methodennamen wird schon bei der Konfiguration mit "Meinten Sie"-Hinweis erkannt, Strict-Mode-Fehler zeigen die passende allows()-Zeile (oder das Aufrufprotokoll, falls die Methode anderswo aufgerufen wurde), und automatisch generierte Loose-Mode-Doubles weisen in ihren Meldungen auf ihre Herkunft hin.

Mit installiertem PHPUnit (Versionen 11 und 12, zur Laufzeit erkannt) erben Fehler von AssertionFailedError, sodass unerfüllte Erwartungen als Failures statt Errors gemeldet werden, erfolgreiche Verifikationen als echte Assertions zählen und das VerifiesDoubles-Trait jedes während eines Tests erzeugte Double automatisch verifiziert. PHPUnit ist aber keine Voraussetzung.

Für die Migration enthält die Dokumentation eine Methoden-für-Methoden-Zuordnung von Mockery: mock() wird zu Double::for(), Spies zu received(), shouldIgnoreMissing() ist das Standard-Loose-Verhalten, shouldDeferMissing() wird zu passthru($realInstance), shouldReceive('foo')->once()->andReturn($x) zu expects('foo')->returns($x), andThrow/andReturnUsing zu throws()/resolves(). Matcher mappen eins zu eins: Mockery::on() zu Argument::satisfies(), isSame() zu same(), andAnyOtherArgs() zu remaining(). Einige Mockery-Features fehlen bewusst — Aliasse, ducktype(), Mocking statischer Methoden, globally()-Ordering und byDefault() — jeweils mit dokumentierter Alternative. Ein kostenloser Double Converter auf laravelshift.com automatisiert die Umstellung.

Installiert wird per composer require --dev jasonmccreary/double, ohne Service Provider oder Konfigurationsdatei — es funktioniert in PHPUnit- oder Pest-Suites, mit oder ohne Laravel. Um finale Klassen zu doublen, ruft man Double::bypassFinals() als erste Zeile des PHPUnit-Bootstraps auf, vor dem Autoloader — das final-Schlüsselwort wird vor der Kompilierung umgeschrieben, bereits anderweitig geladene Klassen bleiben jedoch abgelehnt. Die vollständige Dokumentation steht auf testdoublephp.com; der Code liegt auf GitHub unter jasonmccreary/double.