Testing HTTP Clients: Fake Responses, Retries and Error Paths with PHPUnit
AI generated
@test
assert
PHPUnit · HTTP Clients · Guzzle · Fake Responses
Testing HTTP Clients: Fake Responses, Retries and Error Paths
without real network requests

Anyone who tests HTTP clients only with real API calls ends up with slow, fragile tests that turn red the moment the network hiccups. Guzzle's MockHandler, response stacks and custom HTTP adapters give you full control over every response scenario, success, timeout, server outage and retry cascade, directly inside PHPUnit, deterministic and fast.

15 min read MockHandler · Response Stack · Retry Middleware · ConnectException PHP 8.4 · PHPUnit 11 · Guzzle 7

1. The problem with real HTTP calls in tests

HTTP clients are among the most poorly tested components in PHP projects. The typical approach: a unit test calls a method that internally uses Guzzle, and hopes the external API is reachable. The result is tests that fail in CI the moment the API responds slowly, a rate limit is hit, or the test environment blocks outbound traffic. Even more serious is the complete absence of tests for error paths: what happens when the API returns a 503? Is the retry logic actually triggered? Does the caller get a comprehensible exception?

These questions cannot be reliably answered without fake responses. Guzzle offers a complete solution with the MockHandler: a stack of prepared response objects or exceptions replaces the real HTTP transport. Every request the client makes is worked off against this stack. The test has full control, over status codes, headers, bodies, delays and error types. The following sections show how to systematically use this control for every relevant scenario.

2. Guzzle MockHandler: building response stacks

Getting started with testing HTTP clients begins with the MockHandler from the guzzlehttp/guzzle package. The handler accepts an ordered queue of Response objects or Throwable instances. Every HTTP call consumes the next element off the stack, and the order matches exactly the order in which the code makes HTTP calls. That makes it possible to model complex scenarios: first an authentication call, then a data fetch, finally a write call.

Wiring the MockHandler correctly into the Guzzle client matters. The handler is plugged in via the HandlerStack, not passed directly as an option. That way all middlewares, including the retry middleware, stay active and are not bypassed by the mock. That is the crucial difference from directly mocking the entire Guzzle client via createMock(ClientInterface::class): the MockHandler exercises the real code path including the middleware chain, only the transport is replaced with prepared responses.


<?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. Testing error paths: timeouts, 5xx and ConnectException

The real value of fake responses lies in simulating error paths that are rare in production but critical. A 500 Internal Server Error, a 429 Too Many Requests, or a network timeout are hard to reliably provoke with real API calls. With the MockHandler these scenarios become trivial test cases: put the corresponding Response or RequestException on the stack and verify that the code reacts correctly.

For network errors like timeouts or dropped connections, Guzzle uses ConnectException and RequestException. A ConnectException is thrown when no connection can be established, a timeout at the DNS or TCP level. A RequestException covers all HTTP-level failures. Both are queued in the MockHandler stack as a Throwable and thrown on the next request. That lets you test whether your own service class throws its own domain exception in these cases, whether it logs the error, and whether the caller gets a meaningful response.


<?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. Verifying retry logic: how many attempts really happened?

Retry mechanisms are one of the most frequently misimplemented features in HTTP clients. The code looks correct, but it is unclear whether it actually retries three times on a 503 or whether the exception is immediately rethrown. The MockHandler provides full certainty here: put exactly as many responses on the stack as attempts are expected. When the stack is exhausted and another request comes in, the MockHandler itself throws an exception, reliable proof that too many attempts occurred.

Even more precise is using Guzzle's history middleware. It records every request made together with its associated response object. After the test, you can check exactly how many requests were made, which URLs were called, which headers were sent, and whether the exponential backoff would have produced the correct timestamps. This combination of response stack and history middleware gives airtight proof of how the retry logic behaves under every error condition.


<?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. A custom HTTP adapter for full control

For especially complex scenarios, for instance when the HTTP client sits behind its own abstraction, a dedicated test adapter is the cleanest solution. Instead of instantiating the Guzzle client directly, define an interface HttpClientInterface with a request() method, and have the service class accept this interface via constructor injection. In the test, a FakeHttpClient implementation is passed in, which manages a configurable response list and logs every request.

This approach has a decisive advantage over the MockHandler stack: the test classes have no dependency on Guzzle-internal classes. If Guzzle is ever replaced with a different HTTP library, the tests stay unchanged. The FakeHttpClient class is part of the test code, can contain any verification logic you like, and can be configured through a fluent API. It is especially valuable when several tests share the same base configuration and prepare it in setUp.


<?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: checking headers, body and URL

Testing HTTP clients is not exhausted by checking what comes back. At least as important is control over what is sent: are the correct Authorization headers set? Does the request body contain the expected fields in the right format? Is the API endpoint correctly assembled from the configuration? Guzzle's history middleware fully answers these questions, it records the complete request object, including URI, method, headers and stream body.

For complex assertions on the request body, a helper method that decodes the stream body of the RequestInterface into an associative array is useful. That way individual JSON fields can be checked with assertSame without having to compare the entire JSON document. Especially in Magento projects, where HTTP clients are often responsible for external payment providers, ERP systems and fulfillment APIs, this level of request validation is indispensable for trusting the integration.

7. Strategies compared

Several approaches are available for testing HTTP clients in PHP. The choice depends on the abstraction level of your own architecture and the scope of the test requirements.

Strategy Effort Middleware tested Recommendation
Guzzle MockHandler Low Yes Default choice for direct Guzzle use
MockHandler + History Low-Medium Yes When request details need verification
FakeHttpClient Medium No (own abstraction) With interface abstraction, no Guzzle dependency
createMock(ClientInterface) Low No Only for trivial, middleware-free calls
WireMock / real API High Yes Only for integration tests, not in unit test suite

The combination of MockHandler + history middleware covers most of the requirements as long as Guzzle is used directly. For architectures with their own HTTP abstraction interface, the FakeHttpClient is the more robust choice, since it is independent of the concrete HTTP library. Directly mocking ClientInterface with createMock should only be used when no middleware logic needs testing, which in practice is rarely the case.

8. Summary

Testing HTTP clients with fake responses in PHPUnit is not an optional quality measure, it is a prerequisite for trusting any integration with external APIs. The Guzzle MockHandler provides full control over the response stack without real network requests. Error paths such as 503, timeouts, and ConnectException can be reproduced and verified exactly. The history middleware answers the question of what was sent, headers, body, URL and method, not just what came back. Retry logic is provably verified through the response stack: the stack runs out exactly when too many attempts occurred.

The investment in this kind of testing pays off with every API provider switch, every change to the retry middleware, and every upgrade of the HTTP client library. Tests that cover your own code's behavior under network failures are the foundation of robust production systems, and the confidence that a deployment after a refactoring will not end with unhandled exceptions.

Testing HTTP Clients, The Essentials at a Glance

Use MockHandler

Guzzle MockHandler + HandlerStack replaces the real transport. Middlewares stay active, real code path, fake network.

Simulate error paths

ConnectException and Response(503) on the stack, verifies domain exceptions and error handling in your own code.

Verify retry logic

History middleware + response stack counts the exact number of attempts. Too many attempts lead to an empty stack error.

Validate request content

History stores complete request objects. URL, headers and JSON body are checked individually with assertSame.

9. FAQ: Testing HTTP clients with PHPUnit

1Why MockHandler instead of createMock for Guzzle?
createMock bypasses the middleware chain entirely. MockHandler only replaces the transport, retry, logging and auth middleware stay active and get exercised too.
2How do I simulate a timeout?
new ConnectException('Connection timed out', new Request('GET', '/')) on the stack. It is thrown on the next request() call just like a real timeout.
3How do I check the retry count?
Insert Middleware::history($container) into the stack. After the test: count($container) gives the total number of all requests including retries.
4What happens with an empty stack?
OutOfBoundsException: "Mock queue is empty". Useful as a safety net, too many requests (e.g. unexpected retries) become immediately visible.
5Real HTTP server for tests?
Only for integration tests. MockHandler is faster, deterministic and free of process overhead for unit tests.
6How do I test sent headers?
$container[0]['request']->getHeaderLine('Authorization') from the history container returns the sent Authorization header, directly assertable.
7FakeHttpClient vs. MockHandler?
FakeHttpClient with your own interface abstraction, independent of Guzzle. MockHandler directly when using Guzzle and middlewares need to be exercised too.
8Testing token refresh on 401?
Stack: [Response(401), Response(200 token), Response(200 data)]. History verifies the order and checks that the Authorization header is correctly set on the third request.
9MockHandler and async requests?
Works. requestAsync() consumes the same stack. Utils::settle() resolves all promises and enables assertions on results and errors.
10Integrating MockHandler into Magento 2?
Guzzle client via constructor injection. Pass a MockHandler client in the test. No Magento bootstrap needed, pure PHP unit tests with no framework overhead.