Fortgeschrittene Mock-Konfiguration und wo die Grenze zu echter Testlogik verläuft
willReturn() deckt den überwiegenden Teil aller Mock-Bedürfnisse ab, einen festen Wert zurückgeben, fertig. Manchmal reicht das aber nicht: Ein Mock muss abhängig vom übergebenen Argument unterschiedlich reagieren, oder sein Verhalten muss sich über mehrere Aufrufe hinweg dynamisch ändern. Genau dafür gibt es willReturnMap und willReturnCallback. Dieser Artikel zeigt, wie beide funktionieren und wo die Grenze liegt, ab der ein Mock eigentlich schon zu viel Logik enthält.
Inhaltsverzeichnis
- 1. Warum willReturn allein manchmal nicht ausreicht
- 2. willReturnMap: Rückgabewerte abhängig vom Eingabeargument
- 3. willReturnCallback: eigene Logik für dynamisches Verhalten
- 4. Unterschiedliches Verhalten über mehrere Aufrufe hinweg
- 5. Die Grenze: wann der Mock selbst zu viel Logik enthält
- 6. Alternative: ein handgeschriebenes Test-Double statt komplexem Callback
- 7. willReturnMap und willReturnCallback in derselben Testklasse kombinieren
- 8. Häufige Fehler bei willReturnMap und willReturnCallback
- 9. Fazit: Mächtigkeit gezielt einsetzen statt sie zur Standardlösung zu machen
- 10. Zusammenfassung
- 11. FAQ
1. Warum willReturn allein manchmal nicht ausreicht
Die überwiegende Mehrheit aller Mock-Konfigurationen in einer Testsuite braucht nicht mehr als willReturn(), ein fester Rückgabewert für eine Methode, unabhängig davon, welche Argumente übergeben werden. Das ist auch gut so, denn ein Mock soll in erster Linie eine kontrollierte, vorhersagbare Ersatzimplementierung einer Abhängigkeit sein, keine eigene Business-Logik enthalten, die selbst wieder getestet werden müsste.
Es gibt aber Situationen, in denen ein simpler fester Rückgabewert die zu testende Logik nicht ausreichend abbildet: Eine getestete Methode ruft dieselbe Mock-Methode mehrfach mit unterschiedlichen Argumenten auf und erwartet dabei unterschiedliche Antworten, oder der Rückgabewert soll sich über aufeinanderfolgende Aufrufe verändern, etwa um einen Retry-Mechanismus zu testen, der beim ersten Versuch scheitert und beim zweiten gelingt.
2. willReturnMap: Rückgabewerte abhängig vom Eingabeargument
willReturnMap() nimmt ein Array von Zeilen entgegen, jede Zeile besteht aus den erwarteten Eingabeargumenten gefolgt vom zugehörigen Rückgabewert als letztem Element. Ruft der getestete Code die gemockte Methode mit Argumenten auf, die exakt einer dieser Zeilen entsprechen, liefert der Mock den passenden Rückgabewert, für nicht erfasste Argumentkombinationen liefert PHPUnit standardmäßig null.
Der klassische Anwendungsfall ist ein Repository- oder Service-Mock, der abhängig von einer übergebenen ID unterschiedliche Objekte zurückgeben soll, etwa in einem Test, der prüft, wie eine Methode mehrere Kundendatensätze anhand ihrer IDs nacheinander abruft und korrekt verarbeitet. Ohne willReturnMap müsste dafür entweder der Mock mehrfach neu konfiguriert oder eine komplexere Callback-Lösung gebaut werden.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CustomerBatchLoaderTest extends TestCase
{
#[Test]
public function laedtMehrereKundenAnhandIhrerIds(): void
{
$repository = $this->createMock(CustomerRepositoryInterface::class);
$repository->method('findById')->willReturnMap([
// [Argument, Rueckgabewert]
[1, new Customer(1, 'Anna Schmidt')],
[2, new Customer(2, 'Bernd Mueller')],
[3, null], // Kunde 3 existiert nicht
]);
$loader = new CustomerBatchLoader($repository);
$result = $loader->loadMany([1, 2, 3]);
self::assertCount(2, $result);
self::assertSame('Anna Schmidt', $result[0]->getName());
}
}
3. willReturnCallback: eigene Logik für dynamisches Verhalten
willReturnCallback() geht noch einen Schritt weiter und akzeptiert ein beliebiges Callable, das bei jedem Aufruf der gemockten Methode mit denselben Argumenten ausgeführt wird, mit denen die echte Methode aufgerufen worden wäre. Der Rückgabewert dieses Callables wird zum Rückgabewert des Mocks, was beliebige, auch mehrstufige oder zustandsabhängige Logik erlaubt, die mit willReturnMap allein nicht abbildbar wäre.
Ein typischer Fall ist ein Mock, der nicht anhand eines exakten Argumentwerts unterscheidet, sondern anhand einer Bedingung, etwa eines Wertebereichs oder eines Musters. Ein Zahlungsdienst-Mock könnte etwa je nach übergebenem Betrag entweder Erfolg oder eine spezifische Fehlermeldung simulieren, eine Logik, die sich mit einer festen Map nicht sauber abdecken lässt, weil sie nicht auf exakter Gleichheit, sondern auf einer Bedingung basiert.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PaymentProcessorTest extends TestCase
{
#[Test]
public function lehntZuHoheBetraegeAb(): void
{
$gateway = $this->createMock(PaymentGatewayInterface::class);
$gateway->method('charge')->willReturnCallback(
function (int $amountInCents): PaymentResult {
if ($amountInCents > 1_000_00) {
return PaymentResult::declined('Amount exceeds gateway limit');
}
return PaymentResult::approved();
}
);
$processor = new PaymentProcessor($gateway);
self::assertTrue($processor->charge(50_00)->isApproved());
self::assertFalse($processor->charge(2_000_00)->isApproved());
}
}
4. Unterschiedliches Verhalten über mehrere Aufrufe hinweg
Ein weiterer Anwendungsfall für willReturnCallback ist das Simulieren von Verhalten, das sich über mehrere Aufrufe derselben Methode verändert, etwa bei einem Retry-Mechanismus, der erst nach zwei fehlgeschlagenen Versuchen erfolgreich sein soll. Da das Callable bei jedem Aufruf neu ausgeführt wird, kann eine geschlossene Variable als Zähler dienen, um den Aufrufstand zu verfolgen.
Alternativ bietet PHPUnit für genau diesen Zweck auch willReturnOnConsecutiveCalls(), das eine Liste von Rückgabewerten für aufeinanderfolgende Aufrufe entgegennimmt. Für einfache, feste Sequenzen ist das oft die lesbarere Wahl, willReturnCallback lohnt sich, sobald die Logik zwischen den Aufrufen komplexer ist als eine simple Werteliste, etwa wenn zusätzlich das übergebene Argument in die Entscheidung einfließen muss.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RetryingHttpClientTest extends TestCase
{
#[Test]
public function gibtErstBeimZweitenVersuchEinErgebnisZurueck(): void
{
$client = $this->createMock(HttpClientInterface::class);
$attempt = 0;
$client->method('get')->willReturnCallback(function () use (&$attempt): Response {
$attempt++;
if ($attempt === 1) {
throw new NetworkTimeoutException();
}
return new Response(200, 'OK');
});
$retryingClient = new RetryingHttpClient($client, maxAttempts: 3);
$response = $retryingClient->get('/health');
self::assertSame(200, $response->getStatusCode());
}
}
5. Die Grenze: wann der Mock selbst zu viel Logik enthält
willReturnCallback ist mächtig, und genau darin liegt die Gefahr: Ein Callback, der selbst mehrere if-Verzweigungen, Schleifen oder komplexe Bedingungen enthält, ist im Grunde eine zweite Implementierung der zu testenden Logik, nur eben innerhalb des Testcodes versteckt. Ein solcher Mock testet dann nicht mehr das Verhalten der echten Klasse, sondern vergleicht implizit zwei parallele Implementierungen derselben Idee miteinander, was den eigentlichen Testzweck untergräbt.
Als Faustregel gilt: Ein willReturnCallback sollte höchstens eine einzige Bedingung oder eine einfache Fallunterscheidung enthalten. Wird die Callback-Logik komplexer, ist das ein starkes Signal, entweder den Test in mehrere fokussierte Einzeltests mit jeweils einfacherem Mock-Setup aufzuteilen, oder zu prüfen, ob ein echtes, handgeschriebenes Test-Double statt eines dynamisch konfigurierten Mocks die bessere Wahl wäre.
6. Alternative: ein handgeschriebenes Test-Double statt komplexem Callback
Sobald ein willReturnCallback über eine einfache Bedingung hinauswächst, lohnt sich der Blick auf ein handgeschriebenes Test-Double, eine einfache Klasse, die das Interface direkt implementiert, statt über PHPUnits Mock-Builder generiert zu werden. Ein solches Double ist regulärer, lesbarer PHP-Code, der sich wie jede andere Klasse debuggen, mit einer eigenen Testsuite absichern und über mehrere Testdateien hinweg wiederverwenden lässt.
Der Kompromiss ist zusätzlicher Erstellungsaufwand gegenüber der Ein-Zeilen-Konfiguration eines Mocks, der sich aber bei wiederholter Nutzung schnell amortisiert. Ein InMemoryCustomerRepository, das ein Array als Speicher nutzt und die echte Interface-Logik naiv aber korrekt implementiert, ist oft wartbarer als ein willReturnCallback, das komplexe Zustandslogik nachbildet, gerade weil es keine PHPUnit-spezifische Mock-API-Kenntnis erfordert, um es zu verstehen.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Double;
final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
/** @var array<int, Customer> */
private array $customers = [];
public function save(Customer $customer): void
{
$this->customers[$customer->getId()] = $customer;
}
public function findById(int $id): ?Customer
{
return $this->customers[$id] ?? null;
}
}
// Verwendung im Test: kein Callback, keine Map, nur echtes Verhalten
final class CustomerServiceTest extends \PHPUnit\Framework\TestCase
{
public function testeAktualisiertBestehendenKunden(): void
{
$repository = new InMemoryCustomerRepository();
$repository->save(new Customer(1, 'Anna Schmidt'));
$service = new CustomerService($repository);
$service->renameCustomer(1, 'Anna Meier');
self::assertSame('Anna Meier', $repository->findById(1)->getName());
}
}
7. willReturnMap und willReturnCallback in derselben Testklasse kombinieren
In der Praxis schließen sich beide Techniken nicht gegenseitig aus, unterschiedliche Methoden desselben Mocks können unterschiedlich konfiguriert werden. Eine findById-Methode eignet sich gut für willReturnMap, weil sie über feste ID-Werte diskriminiert, während eine validate-Methode desselben Mocks möglicherweise willReturnCallback braucht, weil sie eine echte Bedingung statt einer festen Werteliste prüft.
Wichtig ist, pro Methode die einfachste ausreichende Technik zu wählen, nicht aus Gewohnheit überall willReturnCallback einzusetzen, nur weil es die mächtigste Option ist. Ein Mock-Setup, das konsequent die jeweils simpelste Konfiguration nutzt, willReturn wo möglich, willReturnMap wo eine Argumentabhängigkeit besteht, willReturnCallback nur bei echter Notwendigkeit, bleibt für andere Teammitglieder am leichtesten nachvollziehbar.
8. Häufige Fehler bei willReturnMap und willReturnCallback
Ein verbreiteter Fehler bei willReturnMap ist ein Typ-Mismatch zwischen den in der Map hinterlegten Argumenten und den tatsächlich übergebenen Argumenten, etwa ein String '1' in der Map, während der getestete Code eine echte Integer-1 übergibt. Da PHPUnit intern strikt vergleicht, liefert der Mock dann null statt des erwarteten Objekts, und der Fehlschlag zeigt sich oft erst als verwirrende Null-Pointer-Exception weiter unten im getesteten Code, nicht direkt an der Mock-Konfiguration.
Bei willReturnCallback ist der häufigste Fehler, dass die Signatur des Callables nicht exakt zur Methode des gemockten Interfaces passt, etwa eine fehlende oder falsch typisierte Parameterzahl. PHP wirft dann einen TypeError erst zur Laufzeit beim tatsächlichen Aufruf, nicht schon beim Konfigurieren des Mocks, weshalb sich ein sorgfältiger Blick auf die Interface-Signatur beim Schreiben des Callbacks auszahlt.
9. Fazit: Mächtigkeit gezielt einsetzen statt sie zur Standardlösung zu machen
willReturnMap und willReturnCallback lösen echte Probleme, die mit einfachem willReturn nicht abbildbar sind, argumentabhängige Rückgabewerte und dynamisches, zustandsbehaftetes Verhalten über mehrere Aufrufe. Beide Werkzeuge sollten aber gezielt und sparsam eingesetzt werden, sobald die Callback-Logik selbst komplex wird, ist das ein Signal, entweder den Test aufzuteilen oder ein handgeschriebenes Test-Double zu bauen.
Die folgende Tabelle fasst zusammen, welche Technik für welchen Anwendungsfall die richtige Wahl ist, von der einfachen festen Antwort bis zur komplexen, zustandsabhängigen Simulation.
| Technik | Anwendungsfall | Komplexität | Alternative bei Überschreitung |
|---|---|---|---|
| willReturn | Fester Rückgabewert, unabhängig vom Argument | Minimal | Keine nötig |
| willReturnMap | Rückgabewert abhängig von exaktem Argumentwert | Niedrig | willReturnCallback bei Bedingungen |
| willReturnOnConsecutiveCalls | Feste Sequenz über mehrere Aufrufe | Niedrig | willReturnCallback bei Zustandslogik |
| willReturnCallback | Bedingte oder zustandsabhängige Logik | Mittel | Handgeschriebenes Test-Double |
Mironsoft
Testautomatisierung, Magento-Qualitätssicherung und CI-Integration
Tests, die echte Fehler finden statt nur grün zu leuchten?
Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.
Test-Audit
Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.
Teststrategie
Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.
CI-Integration
Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.
10. Zusammenfassung
Fortgeschrittene Mocks: Das Wichtigste auf einen Blick
Kernidee
willReturnMap deckt argumentabhängige, willReturnCallback dynamische oder zustandsbehaftete Mock-Antworten ab.
Größte Gefahr
Ein zu komplexer Callback wird zu einer zweiten Implementierung der getesteten Logik im Mock.
Faustregel
Höchstens eine Bedingung im Callback, darüber hinaus lieber Test aufteilen oder Double schreiben.
Häufigster Fehler
Typ-Mismatch zwischen Map-Argumenten und tatsächlich übergebenen Werten führt zu stillem null.