Test Doubles fuer HTTP-Clients: Guzzle MockHandler in PHPUnit
AI generated
@test
assert
PHPUnit · HTTP · Guzzle
Test Doubles fuer HTTP-Clients
Mit dem Guzzle MockHandler zuverlaessig testen

Wer HTTP-Clients testet, ohne echte Netzwerkaufrufe auszufuehren, braucht realistische Test Doubles. Der Guzzle MockHandler erlaubt es, Erfolgsantworten, Fehlerstatus, Timeouts und kaputte JSON-Payloads praezise zu simulieren und so alle Codepfade eines Clients zuverlaessig abzudecken.

15 Min. Lesezeit Guzzle MockHandler HTTP-Mocking

1. Warum echte HTTP-Aufrufe in Tests ein Problem sind

Ein Testsuite, die bei jedem Lauf tatsaechlich gegen ein externes API telefoniert, ist keine Unit-Testsuite mehr, sondern ein fragiles Integrationsgeflecht. Sie wird langsam, weil jeder HTTP-Roundtrip Millisekunden bis Sekunden kostet, sie wird unzuverlaessig, weil Netzwerkprobleme, Rate Limits oder Wartungsfenster des Drittanbieters ploetzlich rote Builds erzeugen, und sie wird schwer reproduzierbar, weil sich die Antwort des externen Dienstes jederzeit aendern kann, ohne dass sich am eigenen Code etwas geaendert hat.

Die Loesung ist nicht, HTTP-Aufrufe ungetestet zu lassen, sondern die Transportschicht durch ein Test Double zu ersetzen, das exakt so antwortet, wie es der echte Dienst in einem bestimmten Szenario tun wuerde. Genau dafuer bringt Guzzle mit dem MockHandler ein eingebautes Werkzeug mit, das tief in die HandlerStack-Architektur des Clients eingreift und damit realistischer ist als ein generisches Mock-Objekt auf Klassenebene.

2. Grundprinzip: MockHandler und HandlerStack

Guzzle baut jeden HTTP-Client um einen HandlerStack herum, durch den jede Anfrage als letzte Instanz laeuft, bevor eine Antwort zurueckkommt. Der MockHandler ersetzt genau diese letzte Instanz durch eine Warteschlange vordefinierter Antworten. Jede Anfrage, die der Client absetzt, entnimmt die naechste Antwort aus dieser Queue, unabhaengig davon, welche URL oder welche Parameter tatsaechlich uebergeben wurden. Das macht den Handler simpel in der Handhabung, verlangt aber Disziplin bei der Reihenfolge der eingestellten Antworten.

In der Praxis wird der MockHandler mit einem Array aus Response-Objekten instanziiert, per HandlerStack::create() in einen Stack verpackt und dieser Stack dann als handler-Option an den Guzzle-Client uebergeben. Der eigentliche Produktionscode, der den Client per Dependency Injection erhaelt, merkt davon nichts. Er ruft weiterhin get(), post() oder request() auf, bekommt aber ausschliesslich die im Test vordefinierten Antworten zurueck.


use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;

$mock = new MockHandler([
    new Response(200, ['Content-Type' => 'application/json'], '{"status":"ok"}'),
]);

$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);

$response = $client->get('https://api.example.com/status');
// $response ist eine echte Psr7\Response, gefuellt aus der Mock-Queue

3. Erfolgs-Response mit realistischem JSON-Body simulieren

Fuer den Happy Path reicht es selten, nur den Statuscode 200 zu setzen. Wichtig ist, dass der simulierte Response-Body strukturell exakt dem entspricht, was der echte Dienst liefert, inklusive aller Felder, die der eigene Code auswertet. Nur so faengt der Test tatsaechlich Regressionen ab, wenn sich das Parsing des Response-Payloads aendert. Es lohnt sich, echte Beispielantworten aus der API-Dokumentation oder aus einem einmaligen manuellen Testaufruf als Fixture-Dateien abzulegen und im Test einzulesen.

Ein haeufiger Fehler ist, den Content-Type-Header zu vergessen. Manche HTTP-Clients oder Wrapper-Klassen pruefen den Header, bevor sie den Body als JSON dekodieren, und ein fehlender oder falscher Header fuehrt dann zu einem stillen Fehlschlag, der in Produktion nie auftritt, weil der echte Server den Header korrekt setzt. Der Test sollte also den kompletten Header-Satz nachbilden, nicht nur den Statuscode.


public function testFetchesProductSuccessfully(): void
{
    $body = json_encode([
        'id' => 42,
        'sku' => 'TEST-SKU-01',
        'price' => 19.99,
    ]);

    $mock = new MockHandler([
        new Response(200, ['Content-Type' => 'application/json'], $body),
    ]);
    $client = new Client(['handler' => HandlerStack::create($mock)]);

    $service = new ProductApiClient($client);
    $product = $service->fetchProduct('TEST-SKU-01');

    self::assertSame(42, $product->getId());
    self::assertSame(19.99, $product->getPrice());
}

4. Fehlerantworten wie 404 gezielt provozieren

Guzzle wirft standardmaessig eine GuzzleException-Unterklasse, sobald ein Statuscode ausserhalb des 2xx- oder 3xx-Bereichs zurueckkommt, sofern die http_errors-Option nicht deaktiviert ist. Fuer den Test bedeutet das, dass eine simulierte 404-Antwort ueber eine RequestException oder ClientException in die Mock-Queue eingereiht wird, statt ueber ein einfaches Response-Objekt. So laesst sich exakt pruefen, ob der eigene Code diese Exception abfaengt und in eine sinnvolle Domaenen-Exception uebersetzt, statt sie ungefiltert nach oben durchzureichen.

Besonders wichtig ist das bei Wrapper-Services, die aus einer 404-Antwort ein 'Produkt nicht gefunden'-Ergebnis statt eines harten Fehlers machen sollen. Ohne einen Test, der genau diesen Pfad abdeckt, bleibt unklar, ob der Wrapper die Exception wirklich korrekt interpretiert oder ob er versehentlich jede Fehlerantwort gleich behandelt, was in Produktion zu verwirrenden Fehlermeldungen fuehrt.


use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Request;

public function testReturnsNullWhenProductNotFound(): void
{
    $request = new Request('GET', '/products/UNKNOWN');
    $mock = new MockHandler([
        new ClientException(
            'Not Found',
            $request,
            new Response(404, [], '{"error":"not_found"}')
        ),
    ]);
    $client = new Client(['handler' => HandlerStack::create($mock)]);

    $service = new ProductApiClient($client);

    self::assertNull($service->fetchProduct('UNKNOWN'));
}

5. Timeouts und Verbindungsfehler nachbilden

Ein Timeout ist kein HTTP-Statuscode, sondern ein Transportfehler, bei dem gar keine Antwort zustande kommt. Der MockHandler bildet das ueber eine ConnectException ab, die anstelle einer Response in die Queue gestellt wird. Damit laesst sich testen, ob der eigene Code bei einer nicht erreichbaren Gegenstelle mit Retry-Logik, einem Fallback-Wert oder einer klaren Fehlermeldung reagiert, statt den Fehler unbehandelt bis zur Anwendungsschicht durchzureichen.

Gerade Retry-Mechanismen lassen sich mit dem MockHandler elegant absichern: Man reiht zunaechst zwei ConnectException-Instanzen und danach eine erfolgreiche Response in die Queue ein und prueft, ob der Client nach zwei Fehlversuchen beim dritten Versuch tatsaechlich die korrekte Antwort liefert. Ohne ein solches Setup bleibt Retry-Code faktisch ungetestet, weil sich ein echter, kontrollierter Timeout im Testlauf kaum reproduzieren laesst.


use GuzzleHttp\Exception\ConnectException;

public function testRetriesAfterTimeoutAndSucceeds(): void
{
    $request = new Request('GET', '/products/TEST-SKU-01');
    $mock = new MockHandler([
        new ConnectException('Connection timed out', $request),
        new ConnectException('Connection timed out', $request),
        new Response(200, ['Content-Type' => 'application/json'], '{"id":1}'),
    ]);
    $client = new Client(['handler' => HandlerStack::create($mock)]);

    $service = new RetryingProductApiClient($client, maxRetries: 3);
    $product = $service->fetchProduct('TEST-SKU-01');

    self::assertSame(1, $product->getId());
}

6. Kaputte JSON-Payloads und Parsing-Fehler abfangen

Externe APIs liefern nicht immer valides JSON, sei es wegen eines Bugs beim Anbieter, eines abgeschnittenen Response-Bodys bei einem Netzwerkfehler, oder eines HTML-Fehlerdokuments, das ein vorgeschalteter Proxy statt der erwarteten JSON-Antwort zurueckgibt. Ein robuster Client muss auch diesen Fall abfangen und in eine sprechende Exception uebersetzen, statt den rohen json_decode-Fehler oder eine TypeError-Warnung durchzureichen.

Der MockHandler macht es einfach, genau so einen Body zu simulieren: Man setzt einfach einen ungueltigen String als Response-Body, etwa ein abgeschnittenes JSON-Fragment oder reinen HTML-Text mit Statuscode 200. So laesst sich pruefen, ob der eigene Parser diesen Fall erkennt, eine klare Fehlermeldung wirft und nicht versehentlich mit einem Notice weiterlaeuft und stillschweigend leere Objekte erzeugt.


public function testThrowsOnMalformedJsonResponse(): void
{
    $mock = new MockHandler([
        new Response(200, ['Content-Type' => 'application/json'], '{"id": 1, "sku": '),
    ]);
    $client = new Client(['handler' => HandlerStack::create($mock)]);

    $service = new ProductApiClient($client);

    $this->expectException(InvalidApiResponseException::class);
    $service->fetchProduct('TEST-SKU-01');
}

7. Request-Historie mit der History-Middleware pruefen

Neben der reinen Antwort ist oft interessant, was der Client tatsaechlich als Anfrage gesendet hat: welche URL, welche Header, welcher Body. Guzzle bietet dafuer eine History-Middleware, die als zusaetzliche Schicht in den HandlerStack eingehaengt wird und jede Anfrage zusammen mit der zugehoerigen Antwort in ein uebergebenes Array-Objekt schreibt. So laesst sich nach dem Testlauf pruefen, ob der Client zum Beispiel den korrekten Authorization-Header oder die richtige Query-Parameter-Kombination gesendet hat.

Diese Technik ist besonders wertvoll bei Clients, die komplexe Request-Objekte zusammenbauen, etwa mit signierten Headern oder verschachtelten Query-Strings. Ohne Zugriff auf die tatsaechlich gesendete Anfrage bliebe unklar, ob ein Bug in der Response-Verarbeitung oder bereits im Request-Aufbau steckt. Die History-Middleware schliesst diese Luecke, ohne dass ein echter Server involviert sein muss.


use GuzzleHttp\Middleware;

public function testSendsCorrectAuthorizationHeader(): void
{
    $history = [];
    $mock = new MockHandler([
        new Response(200, ['Content-Type' => 'application/json'], '{"id":1}'),
    ]);
    $stack = HandlerStack::create($mock);
    $stack->push(Middleware::history($history));
    $client = new Client(['handler' => $stack]);

    $service = new ProductApiClient($client, apiToken: 'secret-token');
    $service->fetchProduct('TEST-SKU-01');

    /** @var \GuzzleHttp\Psr7\Request $sentRequest */
    $sentRequest = $history[0]['request'];
    self::assertSame('Bearer secret-token', $sentRequest->getHeaderLine('Authorization'));
}

8. Mehrere Szenarien mit einem Data Provider abdecken

Sobald mehrere Fehlerszenarien getestet werden sollen, etwa 404, 500 und ein Timeout, lohnt es sich, die Testmethode zu parametrisieren, statt fuer jedes Szenario eine fast identische Methode zu schreiben. Ein Data Provider liefert dabei fuer jeden Durchlauf ein vorbereitetes Response- oder Exception-Objekt sowie die erwartete Domaenen-Reaktion, sodass der eigentliche Testkoerper schlank bleibt und neue Szenarien nur eine zusaetzliche Zeile im Provider erfordern.

Wichtig ist dabei, dass jeder Data-Provider-Eintrag einen sprechenden Schluessel bekommt, damit ein fehlgeschlagener Testlauf sofort erkennen laesst, welches Szenario betroffen ist, statt nur eine generische Nummer wie 'testX with data set #2' anzuzeigen. Bei PHPUnit-Versionen mit Attribut-Syntax laesst sich das ueber ein assoziatives Array im DataProvider-Attribut sauber abbilden.


use PHPUnit\Framework\Attributes\DataProvider;

public static function errorScenarios(): array
{
    return [
        'not found' => [new Response(404, [], '{}'), null],
        'server error' => [new Response(500, [], '{}'), null],
        'timeout' => [new ConnectException('timeout', new Request('GET', '/x')), null],
    ];
}

#[DataProvider('errorScenarios')]
public function testHandlesVariousErrorResponses(mixed $mockedResult, mixed $expected): void
{
    $mock = new MockHandler([$mockedResult]);
    $client = new Client(['handler' => HandlerStack::create($mock)]);

    $service = new ProductApiClient($client);

    self::assertSame($expected, $service->fetchProduct('ANY-SKU'));
}

9. Best Practices und typische Fallstricke

Der haeufigste Fehler ist, die MockHandler-Queue in falscher Reihenfolge zu befuellen. Da jede Anfrage strikt der Reihe nach die naechste Antwort erhaelt, fuehrt eine zusaetzliche, im Produktionscode unerwartete Anfrage dazu, dass alle folgenden Zuordnungen verschieben und der Test an einer voellig anderen Stelle als der eigentlichen Ursache fehlschlaegt. Es hilft, die Anzahl der erwarteten Requests bewusst zu dokumentieren und im Zweifel ueber die History-Middleware zu verifizieren.

Ein zweiter Fallstrick ist, den MockHandler ausschliesslich fuer Erfolgsfaelle einzusetzen und Fehlerpfade zu vernachlaessigen, weil sie muehsamer zu konstruieren sind. Gerade bei Zahlungs- oder Versand-Integrationen ist aber genau das Verhalten bei Fehlern geschaeftskritisch. Eine Coverage-Betrachtung, die Fehlerpfade separat ausweist, hilft dabei, diese Luecke sichtbar zu machen, statt sie durch eine hohe Gesamtzahl an Happy-Path-Tests zu verdecken.

Szenario Guzzle-Konstrukt Typischer Test-Fokus
Erfolgreiche Antwort new Response(200, ...) Korrektes Parsing des Response-Bodys
Ressource nicht gefunden ClientException mit Response(404) Domaenen-spezifisches Nicht-Gefunden-Ergebnis
Serverfehler ServerException mit Response(500) Retry-Logik oder sprechende Fehlermeldung
Timeout / kein Connect ConnectException Fallback-Verhalten, Retry-Zaehler
Kaputtes JSON Response mit ungueltigem Body-String Werfen einer sprechenden Parsing-Exception

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

Guzzle MockHandler: Das Wichtigste auf einen Blick

Werkzeug

Guzzle MockHandler plus HandlerStack, keine echten Netzwerkaufrufe.

Erfolg

Realistischer JSON-Body inklusive korrekter Header simulieren.

Fehler

ClientException, ServerException und ConnectException gezielt in die Queue stellen.

Kontrolle

History-Middleware prueft, was der Client wirklich gesendet hat.

11. FAQ: Guzzle MockHandler: Das Wichtigste auf einen Blick

1Was ist der Guzzle MockHandler genau?
Der MockHandler ist eine in Guzzle eingebaute Handler-Implementierung, die anstelle eines echten Netzwerkaufrufs eine vordefinierte Warteschlange aus Response- oder Exception-Objekten abarbeitet. Er wird ueber den HandlerStack an den Client gebunden und ersetzt so die Transportschicht vollstaendig, ohne dass der aufrufende Code angepasst werden muss.
2Muss ich fuer jeden Test einen neuen Client erstellen?
Ja, in der Regel wird pro Testmethode ein frisches MockHandler-Objekt mit genau der Queue erzeugt, die dieses Szenario benoetigt. Ein geteilter Client ueber mehrere Tests hinweg fuehrt schnell zu Verwechslungen, weil die Queue-Reihenfolge zwischen den Tests nicht zurueckgesetzt wird.
3Wie simuliere ich einen HTTP-404-Fehler?
Man legt statt eines Response-Objekts eine GuzzleHttp ClientException in die MockHandler-Queue, der ein Request-Objekt und eine Response mit Statuscode 404 uebergeben werden. Guzzle wirft diese Exception automatisch, sobald die Anfrage verarbeitet wird, sofern http_errors nicht deaktiviert ist.
4Wie teste ich einen Verbindungstimeout?
Dafuer reiht man eine GuzzleHttp ConnectException anstelle einer Response in die MockHandler-Queue ein. Diese Exception bildet einen Transportfehler nach, bei dem gar keine HTTP-Antwort zustande kommt, und eignet sich damit ideal zum Testen von Retry- oder Fallback-Logik.
5Wie pruefe ich, welche Anfrage der Client tatsaechlich gesendet hat?
Ueber die Middleware::history()-Middleware, die zusaetzlich in den HandlerStack eingehaengt wird. Sie schreibt jede gesendete Anfrage samt Antwort in ein per Referenz uebergebenes Array, das nach dem Aufruf ausgewertet werden kann, etwa um Header oder Query-Parameter zu pruefen.
6Kann ich mit dem MockHandler auch malformed JSON testen?
Ja, dazu setzt man einfach einen ungueltigen JSON-String als Response-Body, zum Beispiel ein abgeschnittenes Fragment. So laesst sich pruefen, ob der eigene Parser diesen Fall erkennt und eine sprechende Exception wirft, statt stillschweigend mit einem Notice weiterzulaufen.
7Was ist der Unterschied zwischen MockHandler und einem generischen Mock-Objekt?
Ein generisches Mock-Objekt ersetzt meist eine ganze Client-Klasse und testet damit nur die eigene Verwendung dieser Klasse. Der MockHandler ersetzt hingegen nur die unterste Transportschicht von Guzzle, sodass der eigentliche Client-Code inklusive Header-Handling, Middleware und Fehlerbehandlung real durchlaufen wird.
8Wie teste ich Retry-Logik zuverlaessig?
Man reiht mehrere Fehler-Exceptions gefolgt von einer erfolgreichen Response in die MockHandler-Queue ein und prueft, ob der Client nach den erwarteten Fehlversuchen tatsaechlich die korrekte Antwort liefert und nicht mehr oder weniger Versuche unternimmt als konfiguriert.
9Muss ich die Reihenfolge der Antworten in der Queue beachten?
Ja, das ist der wichtigste Stolperstein. Jede Anfrage entnimmt strikt die naechste Antwort aus der Queue, unabhaengig von URL oder Parametern. Eine zusaetzliche oder fehlende Anfrage im Produktionscode verschiebt alle folgenden Zuordnungen und fuehrt zu schwer nachvollziehbaren Testfehlern.
10Eignet sich der MockHandler auch fuer Integrationstests mit Magento-HTTP-Clients?
Ja, sofern der Magento-eigene oder ein Drittanbieter-Service intern einen Guzzle-Client per Dependency Injection erhaelt, laesst sich dieser problemlos durch einen mit MockHandler praeparierten Client ersetzen. Das ist besonders bei Zahlungs- oder Versand-Modulen sinnvoll, die externe REST- oder SOAP-Gateways ansprechen.