Test Doubles for HTTP Clients: Guzzle MockHandler in PHPUnit
AI generated
@test
assert
PHPUnit · HTTP · Guzzle
Test Doubles for HTTP Clients
Testing reliably with the Guzzle MockHandler

Testing an HTTP client without making real network calls requires realistic test doubles. The Guzzle MockHandler lets you precisely simulate success responses, error statuses, timeouts, and broken JSON payloads, covering every code path of a client with confidence.

15 min read Guzzle MockHandler HTTP mocking

1. Why real HTTP calls in tests are a problem

A test suite that actually talks to an external API on every run is no longer a unit test suite, it is a fragile web of integration tests. It becomes slow, because every HTTP round trip costs milliseconds to seconds, it becomes unreliable, because network hiccups, rate limits, or a maintenance window on the third party's side suddenly turn builds red, and it becomes hard to reproduce, because the external service's response can change at any time without anything changing in your own code.

The fix is not to leave HTTP calls untested, but to replace the transport layer with a test double that responds exactly the way the real service would in a given scenario. That is precisely what Guzzle's built in MockHandler provides: it hooks deep into the client's HandlerStack architecture, which makes it more realistic than a generic mock object at the class level.

2. Core idea: MockHandler and HandlerStack

Guzzle builds every HTTP client around a HandlerStack that every request passes through as the final step before a response comes back. The MockHandler replaces exactly that final step with a queue of predefined responses. Every request the client sends pulls the next response off that queue, regardless of which URL or parameters were actually passed. That keeps the handler simple to use, but it demands discipline about the order in which responses are queued.

In practice, the MockHandler is instantiated with an array of Response objects, wrapped into a stack via HandlerStack::create(), and that stack is then passed as the handler option to the Guzzle client. The actual production code, which receives the client via dependency injection, notices none of this. It keeps calling get(), post(), or request(), but only ever receives the responses predefined in the test.


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 is a real Psr7\Response, filled from the mock queue

3. Simulating a success response with a realistic JSON body

For the happy path, it rarely suffices to just set status code 200. What matters is that the simulated response body matches, structurally, exactly what the real service returns, including every field your own code reads. Only then does the test actually catch regressions when the parsing of the response payload changes. It pays off to store real example responses from the API documentation, or from a one off manual test call, as fixture files and read them in during the test.

A common mistake is forgetting the Content-Type header. Some HTTP clients or wrapper classes check the header before decoding the body as JSON, and a missing or wrong header then causes a silent failure that never occurs in production, because the real server sets the header correctly. The test should therefore mirror the full header set, not just the status code.


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. Deliberately triggering error responses like 404

By default Guzzle throws a GuzzleException subclass as soon as a status code outside the 2xx or 3xx range comes back, unless the http_errors option is disabled. For the test, that means a simulated 404 response is queued as a RequestException or ClientException rather than a plain Response object. That lets you check precisely whether your own code catches this exception and translates it into a meaningful domain exception, instead of letting it bubble up unfiltered.

This matters especially for wrapper services that should turn a 404 response into a 'product not found' result instead of a hard failure. Without a test covering exactly this path, it stays unclear whether the wrapper really interprets the exception correctly, or whether it accidentally treats every error response the same way, which leads to confusing error messages in production.


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. Reproducing timeouts and connection errors

A timeout is not an HTTP status code, it is a transport failure where no response ever materializes. The MockHandler models this via a ConnectException that is queued instead of a Response. This lets you test whether your own code reacts to an unreachable endpoint with retry logic, a fallback value, or a clear error message, instead of letting the failure propagate unhandled up to the application layer.

Retry mechanisms in particular are elegantly covered with the MockHandler: queue two ConnectException instances followed by a successful response, then verify that the client actually delivers the correct answer on the third attempt after two failures. Without such a setup, retry code is effectively untested, because a real, controlled timeout is nearly impossible to reproduce reliably in a test run.


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. Catching broken JSON payloads and parsing errors

External APIs do not always return valid JSON, whether due to a bug on the provider's side, a truncated response body caused by a network glitch, or an HTML error page returned by an upstream proxy instead of the expected JSON response. A robust client must handle this case too, translating it into a meaningful exception instead of letting the raw json_decode error or a TypeError warning leak through.

The MockHandler makes it easy to simulate exactly such a body: just set an invalid string as the response body, for example a truncated JSON fragment or plain HTML text with status code 200. This lets you verify that your own parser detects this case, throws a clear error, and does not silently continue with a notice and produce empty objects.


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. Inspecting request history with the history middleware

Beyond the response itself, it is often interesting to see what the client actually sent as a request: which URL, which headers, which body. Guzzle offers a history middleware for this, added as an extra layer in the HandlerStack, that records every request together with its response into an array object passed by reference. After the test run, you can then verify whether the client sent, for example, the correct Authorization header or the right combination of query parameters.

This technique is especially valuable for clients that assemble complex request objects, such as signed headers or nested query strings. Without access to the request actually sent, it would remain unclear whether a bug lives in response processing or already in request construction. The history middleware closes that gap without needing a real server involved.


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. Covering multiple scenarios with a data provider

Once several error scenarios need coverage, such as 404, 500, and a timeout, it pays to parametrize the test method instead of writing a near identical method for each scenario. A data provider then supplies a prepared response or exception object plus the expected domain reaction for each run, keeping the actual test body lean and letting new scenarios require just one extra line in the provider.

It matters that every data provider entry gets a descriptive key, so a failing run immediately shows which scenario is affected instead of just a generic label like 'testX with data set #2'. On PHPUnit versions with attribute syntax, this can be modeled cleanly with an associative array inside the DataProvider attribute.


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 and common pitfalls

The most common mistake is filling the MockHandler queue in the wrong order. Since every request strictly receives the next response in line, an extra request the production code makes unexpectedly shifts every subsequent mapping, and the test fails at a completely different point than the actual root cause. It helps to deliberately document the expected number of requests and, when in doubt, verify it via the history middleware.

A second pitfall is using the MockHandler only for success cases and neglecting error paths because they are more tedious to construct. Yet for payment or shipping integrations especially, behavior on failure is exactly what is business critical. A coverage view that reports error paths separately helps make this gap visible, instead of hiding it behind a high overall count of happy path tests.

Scenario Guzzle construct Typical test focus
Successful response new Response(200, ...) Correct parsing of the response body
Resource not found ClientException with Response(404) Domain specific not found result
Server error ServerException with Response(500) Retry logic or a meaningful error message
Timeout / no connect ConnectException Fallback behavior, retry counter
Broken JSON Response with invalid body string Throwing a meaningful parsing exception

Mironsoft

Test automation, Magento quality assurance, and CI integration

Tests that catch real bugs instead of just turning green?

We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.

Test Audit

Reviewing existing suites for mocking antipatterns and blind spots.

Test Strategy

Meaningfully combining unit, integration, and MFTF tests for Magento projects.

CI Integration

Setting up fast, reliable test runs in GitLab CI or GitHub Actions.

10. Summary

Guzzle MockHandler: The Essentials at a Glance

Tool

Guzzle MockHandler plus HandlerStack, no real network calls.

Success

Simulate a realistic JSON body including correct headers.

Errors

Queue ClientException, ServerException, and ConnectException deliberately.

Verification

History middleware checks what the client actually sent.

11. FAQ: Guzzle MockHandler: The Essentials at a Glance

1What exactly is the Guzzle MockHandler?
The MockHandler is a handler implementation built into Guzzle that, instead of making a real network call, works through a predefined queue of Response or Exception objects. It is attached to the client through the HandlerStack, replacing the transport layer entirely without requiring changes to the calling code.
2Do I need a new client for every test?
Yes, typically a fresh MockHandler object is created per test method with exactly the queue that scenario needs. A client shared across multiple tests quickly leads to mix ups, because the queue order is not reset between tests.
3How do I simulate an HTTP 404 error?
Instead of a Response object, you queue a GuzzleHttp ClientException that is given a Request object and a Response with status code 404. Guzzle throws this exception automatically once the request is processed, provided http_errors is not disabled.
4How do I test a connection timeout?
You queue a GuzzleHttp ConnectException instead of a Response in the MockHandler queue. This exception models a transport failure where no HTTP response ever comes back, making it ideal for testing retry or fallback logic.
5How do I check which request the client actually sent?
Through the Middleware::history() middleware, added as an extra layer in the HandlerStack. It records every sent request along with its response into an array passed by reference, which can be inspected afterward, for example to check headers or query parameters.
6Can I also test malformed JSON with the MockHandler?
Yes, just set an invalid JSON string as the response body, for example a truncated fragment. This lets you verify that your own parser detects the case and throws a meaningful exception instead of silently continuing with a notice.
7What is the difference between MockHandler and a generic mock object?
A generic mock object usually replaces an entire client class, testing only your own usage of that class. The MockHandler, by contrast, replaces only Guzzle's lowest transport layer, so the actual client code, including header handling, middleware, and error handling, runs for real.
8How do I reliably test retry logic?
Queue several failure exceptions followed by a successful response, then verify that the client actually delivers the correct answer after the expected number of failed attempts, and that it does not make more or fewer attempts than configured.
9Does the order of responses in the queue matter?
Yes, that is the most important pitfall. Every request strictly pulls the next response off the queue, regardless of URL or parameters. An extra or missing request in the production code shifts every subsequent mapping and leads to test failures that are hard to trace back.
10Does the MockHandler also work for integration tests with Magento HTTP clients?
Yes, as long as Magento's own service or a third party service receives a Guzzle client via dependency injection, that client can easily be replaced with one prepared using MockHandler. This is especially useful for payment or shipping modules that talk to external REST or SOAP gateways.