willReturnCallback und willReturnMap: fortgeschrittene Mock-Konfiguration
AI generated
@test
assert
PHPUnit · Mocking · Test Doubles
willReturnCallback und willReturnMap
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.

15 Min. Lesezeit willReturnCallback · willReturnMap PHPUnit Mocking

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.

11. FAQ: Fortgeschrittene Mocks: Das Wichtigste auf einen Blick

1Wann sollte ich willReturnMap statt willReturn verwenden?
Immer dann, wenn dieselbe gemockte Methode je nach übergebenem Argument unterschiedliche Rückgabewerte liefern soll, etwa unterschiedliche Objekte je nach ID. Bei einem festen Rückgabewert unabhängig vom Argument bleibt willReturn die einfachere Wahl.
2Was passiert, wenn ein Argument in willReturnMap nicht hinterlegt ist?
PHPUnit liefert in diesem Fall standardmäßig null zurück, ohne Warnung oder Fehler. Das kann zu schwer nachvollziehbaren Fehlschlägen weiter unten im getesteten Code führen, wenn dieser Fall nicht erwartet wurde.
3Kann ich in willReturnCallback auf Testklassen-Eigenschaften zugreifen?
Ja, sofern das Callback als Closure mit use(...) definiert wird oder eine Methode der Testklasse referenziert, lassen sich Testklassen-Eigenschaften wie gewohnt einbinden, etwa ein Zähler für mehrfache Aufrufe.
4Ist willReturnCallback langsamer als willReturn oder willReturnMap?
Der Unterschied ist in der Praxis nicht messbar, alle drei Mechanismen laufen innerhalb desselben Testprozesses ohne echten I/O. Performance ist bei der Wahl der Technik kein relevantes Kriterium.
5Wie erkenne ich, dass ein Callback zu komplex geworden ist?
Ein guter Indikator ist mehr als eine einzelne if-Bedingung oder eine Schleife im Callback-Körper. Sobald der Callback selbst getestet werden müsste, um Vertrauen in seine Korrektheit zu haben, ist er zu komplex für einen Mock.
6Funktioniert willReturnMap auch mit mehreren Argumenten pro Methode?
Ja, jede Zeile der Map kann beliebig viele Argumente vor dem letzten Element, dem Rückgabewert, enthalten. PHPUnit vergleicht dabei alle übergebenen Argumente gemeinsam gegen jede Zeile.
7Sollte ich willReturnCallback für einfache bedingte Logik wie eine einzelne Ungleichung nutzen?
Ja, das ist ein legitimer, häufiger Anwendungsfall, etwa ein Schwellenwertvergleich wie in diesem Artikel gezeigt. Problematisch wird es erst, wenn mehrere solcher Bedingungen kombiniert werden.
8Was ist der Unterschied zwischen willReturnCallback und willReturnOnConsecutiveCalls?
willReturnOnConsecutiveCalls liefert eine feste, vorab definierte Werteliste über aufeinanderfolgende Aufrufe, willReturnCallback erlaubt beliebige Logik pro Aufruf, inklusive Berücksichtigung der übergebenen Argumente.
9Wann lohnt sich ein handgeschriebenes Test-Double gegenüber einem Mock mit Callback?
Sobald dasselbe komplexe Verhalten in mehreren Testdateien benötigt wird oder das Callback selbst mehrstufige Zustandslogik enthält, ist ein wiederverwendbares, direkt implementiertes Test-Double meist wartbarer.
10Kann ich willReturnMap und willReturnCallback für dieselbe Methode kombinieren?
Nein, für eine einzelne Methode kann nur eine Rückgabewert-Konfiguration aktiv sein, die zuletzt aufgerufene überschreibt die vorherige. Unterschiedliche Methoden desselben Mocks können aber unterschiedliche Techniken nutzen.