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.
Table of Contents
- 1. Why real HTTP calls in tests are a problem
- 2. Core idea: MockHandler and HandlerStack
- 3. Simulating a success response with a realistic JSON body
- 4. Deliberately triggering error responses like 404
- 5. Reproducing timeouts and connection errors
- 6. Catching broken JSON payloads and parsing errors
- 7. Inspecting request history with the history middleware
- 8. Covering multiple scenarios with a data provider
- 9. Best practices and common pitfalls
- 10. Summary
- 11. FAQ
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.