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.
Inhaltsverzeichnis
- 1. Warum echte HTTP-Aufrufe in Tests ein Problem sind
- 2. Grundprinzip: MockHandler und HandlerStack
- 3. Erfolgs-Response mit realistischem JSON-Body simulieren
- 4. Fehlerantworten wie 404 gezielt provozieren
- 5. Timeouts und Verbindungsfehler nachbilden
- 6. Kaputte JSON-Payloads und Parsing-Fehler abfangen
- 7. Request-Historie mit der History-Middleware pruefen
- 8. Mehrere Szenarien mit einem Data Provider abdecken
- 9. Best Practices und typische Fallstricke
- 10. Zusammenfassung
- 11. FAQ
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.