@test
assert
PHPUnit · HTTP Clients · Guzzle · Fake Responses
HTTP-Clients testen: Fake Responses, Retries und Fehlerpfade
ohne echte Netzwerkanfragen

Wer HTTP-Clients nur mit echten API-Aufrufen testet, schreibt langsame, fragile Tests, die bei Netzwerkproblemen rot werden. Guzzles MockHandler, Response-Stacks und eigene HTTP-Adapter ermöglichen vollständige Kontrolle über alle Antwortszenarien – Erfolg, Timeout, Serverausfall und Retry-Kaskaden – direkt in PHPUnit, deterministisch und schnell.

15 Min. Lesezeit MockHandler · Response-Stack · Retry-Middleware · ConnectException PHP 8.4 · PHPUnit 11 · Guzzle 7

1. Das Problem mit echten HTTP-Aufrufen in Tests

HTTP-Clients gehören zu den am häufigsten schlecht getesteten Komponenten in PHP-Projekten. Der typische Ansatz: ein Unit-Test ruft eine Methode auf, die intern Guzzle verwendet, und hofft, dass die externe API erreichbar ist. Das Ergebnis sind Tests, die im CI-System fehlschlagen, sobald die API langsam antwortet, ein Ratelimit erreicht wird oder die Testumgebung keinen Outbound-Traffic erlaubt. Noch schwerwiegender ist das Fehlen von Tests für Fehlerpfade: Was passiert, wenn die API einen 503 zurückgibt? Wird die Retry-Logik wirklich ausgelöst? Bekommt der Aufrufer eine verständliche Exception?

Diese Fragen lassen sich ohne Fake Responses nicht zuverlässig beantworten. Guzzle bietet mit dem MockHandler eine vollständige Lösung: Ein Stack aus vorbereiteten Response-Objekten oder Exceptions ersetzt den echten HTTP-Transport. Jede Anfrage, die der Client stellt, wird gegen diesen Stack abgearbeitet. Der Test hat vollständige Kontrolle – über Statuscodes, Headers, Bodies, Delays und Fehlertypen. Die folgenden Abschnitte zeigen, wie man diese Kontrolle systematisch für alle relevanten Szenarien nutzt.

2. Guzzle MockHandler: Response-Stacks aufbauen

Der Einstieg in das Testen von HTTP-Clients beginnt mit dem MockHandler aus dem Paket guzzlehttp/guzzle. Der Handler nimmt eine geordnete Warteschlange von Response-Objekten oder Throwable-Instanzen entgegen. Jeder HTTP-Aufruf konsumiert das nächste Element aus dem Stack – die Reihenfolge entspricht genau der Reihenfolge, in der der Code HTTP-Aufrufe macht. Das erlaubt es, komplexe Szenarien zu modellieren: erst ein Authentifizierungsaufruf, dann ein Datenabruf, schließlich ein Schreibaufruf.

Wichtig ist die korrekte Einbindung des MockHandlers in den Guzzle-Client. Der Handler wird über den HandlerStack eingebaut, nicht direkt als Option übergeben. So bleiben alle Middlewares – einschließlich der Retry-Middleware – aktiv und werden durch den Mock nicht umgangen. Das ist der entscheidende Unterschied zu einem direkten Mock des gesamten Guzzle-Clients per createMock(ClientInterface::class): Der MockHandler testet den echten Code-Pfad inklusive Middleware-Kette, nur der Transport wird durch vorbereitete Antworten ersetzt.


<?php
// tests/Unit/ApiClientTest.php
declare(strict_types=1);

namespace Tests\Unit;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use App\Service\ProductApiClient;

class ApiClientTest extends TestCase
{
    private function buildClient(array $responses): ProductApiClient
    {
        $mock    = new MockHandler($responses);
        $stack   = HandlerStack::create($mock);
        $guzzle  = new Client(['handler' => $stack, 'base_uri' => 'https://api.example.com']);

        return new ProductApiClient($guzzle);
    }

    public function testFetchesProductSuccessfully(): void
    {
        $client = $this->buildClient([
            new Response(200, ['Content-Type' => 'application/json'], json_encode([
                'id' => 42, 'name' => 'Widget Pro', 'price' => 19.99,
            ])),
        ]);

        $product = $client->getProduct(42);

        $this->assertSame(42, $product->getId());
        $this->assertSame('Widget Pro', $product->getName());
        $this->assertEqualsWithDelta(19.99, $product->getPrice(), 0.001);
    }

    public function testHandlesEmptyResponseBody(): void
    {
        $client = $this->buildClient([new Response(204)]);

        $result = $client->deleteProduct(42);

        $this->assertTrue($result);
    }
}

3. Fehlerpfade testen: Timeouts, 5xx und ConnectException

Der eigentliche Wert von Fake Responses liegt in der Simulation von Fehlerpfaden, die in der Produktion selten, aber kritisch sind. Ein 500 Internal Server Error, ein 429 Too Many Requests oder ein Netzwerk-Timeout lassen sich mit echten API-Aufrufen kaum zuverlässig provozieren. Mit dem MockHandler werden diese Szenarien zu trivialen Testfällen: Man legt die entsprechende Response oder RequestException in den Stack und verifiziert, dass der Code korrekt reagiert.

Für Netzwerkfehler wie Timeouts oder unterbrochene Verbindungen verwendet Guzzle ConnectException und RequestException. Eine ConnectException wird ausgelöst, wenn keine Verbindung aufgebaut werden kann – Timeout auf DNS oder TCP-Ebene. Eine RequestException deckt alle HTTP-Ebenen-Fehler ab. Beide werden im MockHandler-Stack als Throwable eingereiht und beim nächsten Request ausgelöst. So lässt sich testen, ob die eigene Service-Klasse in diesen Fällen eine eigene Domain-Exception wirft, ob sie den Fehler loggt und ob der Aufrufer eine sinnvolle Rückmeldung bekommt.


<?php
// tests/Unit/ApiClientErrorTest.php
declare(strict_types=1);

namespace Tests\Unit;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use App\Exception\ApiUnavailableException;
use App\Exception\RateLimitException;
use App\Service\ProductApiClient;

class ApiClientErrorTest extends TestCase
{
    public function testThrowsDomainExceptionOn500(): void
    {
        $mock  = new MockHandler([new Response(500, [], 'Internal Server Error')]);
        $stack = HandlerStack::create($mock);
        $client = new ProductApiClient(new Client(['handler' => $stack]));

        $this->expectException(ApiUnavailableException::class);
        $this->expectExceptionMessage('API returned 500');

        $client->getProduct(1);
    }

    public function testThrowsRateLimitExceptionOn429(): void
    {
        $mock  = new MockHandler([new Response(429, ['Retry-After' => '60'], 'Too Many Requests')]);
        $stack = HandlerStack::create($mock);
        $client = new ProductApiClient(new Client(['handler' => $stack]));

        $this->expectException(RateLimitException::class);

        $client->getProduct(1);
    }

    public function testThrowsOnConnectTimeout(): void
    {
        $request = new Request('GET', '/products/1');
        $mock    = new MockHandler([
            new ConnectException('Connection timed out', $request),
        ]);
        $stack   = HandlerStack::create($mock);
        $client  = new ProductApiClient(new Client(['handler' => $stack]));

        $this->expectException(ApiUnavailableException::class);
        $this->expectExceptionMessage('Connection timed out');

        $client->getProduct(1);
    }

    /** @dataProvider errorStatusProvider */
    public function testAllErrorStatusesThrow(int $status): void
    {
        $mock  = new MockHandler([new Response($status)]);
        $stack = HandlerStack::create($mock);
        $client = new ProductApiClient(new Client(['handler' => $stack]));

        $this->expectException(\RuntimeException::class);
        $client->getProduct(1);
    }

    public static function errorStatusProvider(): array
    {
        return [[400], [401], [403], [404], [500], [502], [503]];
    }
}

4. Retry-Logik verifizieren: wie oft wurde wirklich versucht?

Retry-Mechanismen sind eine der am häufigsten falsch implementierten Funktionen in HTTP-Clients. Der Code sieht korrekt aus, aber es ist unklar, ob er bei einem 503 tatsächlich dreimal versucht oder ob die Exception sofort weiter geworfen wird. Der MockHandler gibt hier vollständige Sicherheit: Man legt genau so viele Antworten in den Stack, wie Versuche erwartet werden. Wenn der Stack erschöpft ist und ein weiterer Request kommt, wirft der MockHandler selbst eine Exception – ein zuverlässiger Nachweis, dass zu viele Versuche stattfanden.

Noch präziser ist die Nutzung des History-Middlewares von Guzzle. Es zeichnet jeden abgesetzten Request inklusive des zugehörigen Response-Objekts auf. Nach dem Test lässt sich exakt prüfen, wie viele Requests gestellt wurden, welche URLs angefragt wurden, welche Headers mitgesendet wurden und ob der Exponential-Backoff korrekte Zeitstempel produziert hätte. Diese Kombination aus Response-Stack und History-Middleware liefert lückenlosen Nachweis über das Verhalten der Retry-Logik unter allen Fehlerbedingungen.


<?php
// tests/Unit/RetryLogicTest.php
declare(strict_types=1);

namespace Tests\Unit;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use App\Service\ProductApiClient;

class RetryLogicTest extends TestCase
{
    public function testRetriesThreeTimesOn503ThenSucceeds(): void
    {
        $history    = [];
        $historyMw  = Middleware::history($history);

        // Two failures, then success
        $mock  = new MockHandler([
            new Response(503),
            new Response(503),
            new Response(200, [], json_encode(['id' => 1, 'name' => 'Widget'])),
        ]);

        $stack = HandlerStack::create($mock);
        $stack->push($historyMw, 'history');
        // Attach retry middleware (implemented in ProductApiClient::buildHandlerStack)
        $stack->push(ProductApiClient::buildRetryMiddleware(maxRetries: 3), 'retry');

        $guzzle = new Client(['handler' => $stack]);
        $client = new ProductApiClient($guzzle);

        $product = $client->getProduct(1);

        // Three requests were made: two failures + one success
        $this->assertCount(3, $history);
        $this->assertSame(503, $history[0]['response']->getStatusCode());
        $this->assertSame(503, $history[1]['response']->getStatusCode());
        $this->assertSame(200, $history[2]['response']->getStatusCode());
        $this->assertSame(1, $product->getId());
    }

    public function testGivesUpAfterMaxRetries(): void
    {
        $mock  = new MockHandler([
            new Response(503),
            new Response(503),
            new Response(503),
            new Response(503), // Fourth would be too many
        ]);

        $stack = HandlerStack::create($mock);
        $stack->push(ProductApiClient::buildRetryMiddleware(maxRetries: 3), 'retry');

        $guzzle = new Client(['handler' => $stack]);
        $client = new ProductApiClient($guzzle);

        $this->expectException(\App\Exception\ApiUnavailableException::class);
        $client->getProduct(1);
    }
}

5. Eigener HTTP-Adapter für vollständige Kontrolle

Für besonders komplexe Szenarien – etwa wenn der HTTP-Client hinter einer eigenen Abstraktion liegt – ist ein dedizierter Test-Adapter die sauberste Lösung. Statt den Guzzle-Client direkt zu instanziieren, definiert man ein Interface HttpClientInterface mit einer request()-Methode, und die Service-Klasse akzeptiert dieses Interface per Constructor Injection. Im Test wird eine FakeHttpClient-Implementierung übergeben, die eine konfigurierbare Antwortliste verwaltet und jeden Request protokolliert.

Dieser Ansatz hat gegenüber dem MockHandler-Stack einen entscheidenden Vorteil: Die Testklassen haben keine Abhängigkeit zu Guzzle-internen Klassen. Wenn Guzzle irgendwann durch eine andere HTTP-Bibliothek ersetzt wird, bleiben die Tests unverändert. Die FakeHttpClient-Klasse ist Teil des Test-Codes, kann beliebige Prüflogik enthalten und lässt sich über eine fluente API konfigurieren. Sie ist insbesondere dann wertvoll, wenn mehrere Tests dieselbe Grundkonfiguration teilen und diese per setUp vorbereiten.


<?php
// tests/Doubles/FakeHttpClient.php
declare(strict_types=1);

namespace Tests\Doubles;

use App\Http\HttpClientInterface;
use App\Http\HttpResponse;

/**
 * Fake HTTP client for testing — records requests and returns configured responses.
 */
final class FakeHttpClient implements HttpClientInterface
{
    /** @var HttpResponse[] */
    private array $queue = [];
    /** @var array<int, array{method: string, uri: string, options: array<mixed>}> */
    private array $recordedRequests = [];

    public function addResponse(HttpResponse $response): self
    {
        $this->queue[] = $response;
        return $this;
    }

    public function addJsonResponse(int $status, array $data): self
    {
        return $this->addResponse(new HttpResponse($status, json_encode($data) ?: '{}'));
    }

    /**
     * @param array<mixed> $options
     */
    public function request(string $method, string $uri, array $options = []): HttpResponse
    {
        $this->recordedRequests[] = ['method' => $method, 'uri' => $uri, 'options' => $options];

        if (empty($this->queue)) {
            throw new \UnderflowException('FakeHttpClient: response queue is empty');
        }

        $response = array_shift($this->queue);

        if ($response->getStatusCode() >= 500) {
            throw new \App\Exception\ApiUnavailableException('API returned ' . $response->getStatusCode());
        }

        return $response;
    }

    public function getRequestCount(): int
    {
        return count($this->recordedRequests);
    }

    /** @return array<mixed> */
    public function getRequest(int $index): array
    {
        return $this->recordedRequests[$index] ?? throw new \OutOfBoundsException("No request at index $index");
    }
}

6. Request-Assertions: Header, Body und URL prüfen

Das Testen von HTTP-Clients erschöpft sich nicht darin, zu prüfen, was zurückgegeben wird. Mindestens genauso wichtig ist die Kontrolle darüber, was gesendet wird: Werden die korrekten Authorization-Header gesetzt? Enthält der Request-Body die erwarteten Felder im richtigen Format? Wird der API-Endpunkt korrekt aus der Konfiguration zusammengesetzt? Das Guzzle History-Middleware beantwortet diese Fragen vollständig – es zeichnet das vollständige Request-Objekt auf, inklusive URI, Methode, Headers und Stream-Body.

Für komplexe Assertions auf den Request-Body bietet sich eine Hilfsmethode an, die den Stream-Body des RequestInterface in ein assoziatives Array dekodiert. So lassen sich einzelne JSON-Felder mit assertSame prüfen, ohne das gesamte JSON-Dokument vergleichen zu müssen. Besonders in Magento-Projekten, wo HTTP-Clients oft für externe Zahlungsanbieter, ERP-Systeme und Fulfillment-APIs zuständig sind, ist diese Ebene der Request-Validierung unverzichtbar für das Vertrauen in die Integration.

7. Strategien im Vergleich

Für das Testen von HTTP-Clients in PHP stehen verschiedene Ansätze zur Verfügung. Die Wahl hängt vom Abstraktionslevel der eigenen Architektur und vom Umfang der Testanforderungen ab.

Strategie Aufwand Middleware getestet Empfehlung
Guzzle MockHandler Gering Ja Standardfall bei direkter Guzzle-Nutzung
MockHandler + History Gering–Mittel Ja Wenn Request-Details geprüft werden müssen
FakeHttpClient Mittel Nein (eigene Abstraktion) Bei Interface-Abstraktion, keine Guzzle-Abhängigkeit
createMock(ClientInterface) Gering Nein Nur für triviale, middlewarefreie Aufrufe
WireMock / echte API Hoch Ja Nur für Integrationstests, nicht in Unit-Test-Suite

Die Kombination aus MockHandler + History-Middleware deckt den größten Teil der Anforderungen ab, solange Guzzle direkt verwendet wird. Für Architekturen mit eigenem HTTP-Abstraktions-Interface ist der FakeHttpClient die robustere Wahl, da er unabhängig von der konkreten HTTP-Bibliothek ist. Das direkte Mocken von ClientInterface mit createMock sollte nur dann eingesetzt werden, wenn keine Middleware-Logik getestet werden muss – was in der Praxis selten der Fall ist.

8. Zusammenfassung

Das Testen von HTTP-Clients mit Fake Responses in PHPUnit ist keine optionale Qualitätsmaßnahme, sondern Voraussetzung für Vertrauen in jede Integration mit externen APIs. Der Guzzle MockHandler ermöglicht vollständige Kontrolle über den Response-Stack ohne echte Netzwerkanfragen. Fehlerpfade wie 503, Timeouts und ConnectException lassen sich exakt reproduzieren und verifizieren. Das History-Middleware beantwortet die Frage, was gesendet wurde – Headers, Body, URL und Methode – nicht nur, was zurückkam. Retry-Logik wird durch den Response-Stack nachweislich verifiziert: der Stack erschöpft sich genau dann, wenn zu viele Versuche stattfanden.

Die Investition in diese Testart zahlt sich bei jedem API-Provider-Wechsel, bei jeder Änderung an der Retry-Middleware und bei jedem Upgrade der HTTP-Client-Bibliothek aus. Tests, die das Verhalten des eigenen Codes unter Netzwerkfehlern abdecken, sind die Grundlage für robuste Produktionssysteme – und für das Vertrauen, dass eine Deployment nach einem Refactoring nicht mit unbehandelten Exceptions endet.

HTTP-Clients testen — Das Wichtigste auf einen Blick

MockHandler einsetzen

Guzzle MockHandler + HandlerStack ersetzt den echten Transport. Middlewares bleiben aktiv – echter Code-Pfad, fake Netzwerk.

Fehlerpfade simulieren

ConnectException und Response(503) in den Stack – verifiziert Domain-Exceptions und Fehlerbehandlung im eigenen Code.

Retry-Logik prüfen

History-Middleware + Response-Stack zählt exakt die Anzahl der Versuche. Zu viele Versuche führen zu einem leeren Stack-Fehler.

Request-Inhalt validieren

History speichert vollständige Request-Objekte. URL, Headers und JSON-Body werden einzeln mit assertSame geprüft.

9. FAQ: HTTP-Clients testen mit PHPUnit

1Warum MockHandler statt createMock für Guzzle?
createMock umgeht die Middleware-Kette vollständig. MockHandler ersetzt nur den Transport – Retry, Logging und Auth-Middleware bleiben aktiv und werden mitgetestet.
2Wie simuliere ich einen Timeout?
new ConnectException('Connection timed out', new Request('GET', '/')) in den Stack. Wird beim nächsten request()-Aufruf ausgelöst wie ein echter Timeout.
3Wie prüfe ich die Retry-Anzahl?
Middleware::history($container) in den Stack einfügen. Nach dem Test: count($container) gibt die Gesamtanzahl aller Requests inkl. Retries an.
4Was passiert bei leerem Stack?
OutOfBoundsException: "Mock queue is empty". Nützlich als Sicherheitsnetz – zu viele Requests (z.B. unerwartete Retries) werden sofort sichtbar.
5Echter HTTP-Server für Tests?
Nur für Integrationstests. MockHandler ist für Unit Tests schneller, deterministischer und ohne Prozess-Overhead.
6Wie teste ich gesendete Headers?
$container[0]['request']->getHeaderLine('Authorization') aus dem History-Container gibt den gesendeten Authorization-Header zurück – direkt assertierbar.
7FakeHttpClient vs. MockHandler?
FakeHttpClient bei eigener Interface-Abstraktion – unabhängig von Guzzle. MockHandler direkt bei Guzzle-Nutzung, wenn Middlewares mitgetestet werden sollen.
8Token-Refresh bei 401 testen?
Stack: [Response(401), Response(200 Token), Response(200 Daten)]. History verifiziert Reihenfolge und prüft, ob der Authorization-Header beim dritten Request korrekt gesetzt ist.
9MockHandler und async-Requests?
Funktioniert. requestAsync() konsumiert denselben Stack. Utils::settle() löst alle Promises auf und ermöglicht Assertions auf Ergebnis und Fehler.
10MockHandler in Magento 2 integrieren?
Guzzle-Client per Constructor-Injection. Im Test MockHandler-Client übergeben. Kein Magento-Bootstrap nötig – reine PHP Unit Tests ohne Framework-Overhead.