skip to content

How do you unit-test code that uses a Guzzle client with MockHandler, and why wrap it in HandlerStack::create()?

level: middleimportance: should knowfreq 40%

answer

  1. replace the transport, not the client
  2. queue of responses and exceptions
  3. empty queue throws OutOfBoundsException
  4. http_errors needs the default stack
  5. Middleware::history records requests

basics

~20 s

Inject a Client whose handler is HandlerStack::create(new MockHandler([...])); queued responses and exceptions are returned in order without network access. The stack matters because http_errors and other options are middleware; a bare MockHandler never throws on 4xx or 5xx.

solid answer

~40 s

I don't mock the `Client` class; I replace its transport. `new MockHandler([...])` holds a queue of `Response` objects, exceptions, promises or callables, and each request shifts the next one off. I wrap it with `HandlerStack::create($mock)` (or `MockHandler::createWithMiddleware([...])`) and pass that as the client's `handler`. The wrapping matters because `http_errors`, redirects and cookies are middleware added by `create()`. With a bare mock, a queued 500 comes back as a normal response and the error-handling path goes untested. When the queue is empty, the mock throws `OutOfBoundsException('Mock queue is empty')`, which catches unexpected extra calls. To assert what was sent, I push `Middleware::history($container)` onto the stack and inspect each transaction's `request` and `options`. `getLastRequest()` and `count()` on the mock help too.

code

php · 24 lines
php
<?php
declare(strict_types=1);

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;

$mock = new MockHandler([
    new ConnectException('timed out', new Request('GET', 'rates')),
    new Response(200, ['Content-Type' => 'application/json'], '{"USD":1.09}'),
]);
$history = [];
$stack = HandlerStack::create($mock); // keeps http_errors and friends
$stack->push(Middleware::history($history));

$client = new Client(['handler' => $stack, 'base_uri' => 'https://fx-a.example.test/']);
$service = new RatesService($client); // code under test receives the client

$rate = $service->eurTo('USD'); // expected: fails once, falls back, succeeds
// assert $rate === 1.09, count($history) === 2, $mock->count() === 0

go deeper

for a junior

Know that MockHandler returns queued responses in order and that the client receives it through the handler option.

for a middle

Explain why HandlerStack::create() must wrap the mock, how to queue exceptions, and what the empty-queue OutOfBoundsException tells you.

for a senior

Cover failure paths (ConnectException, 5xx, 401), verify outgoing requests with history middleware, and design services to receive their client so tests can inject it.

for a principal

Set a team convention for HTTP boundaries: injected clients, a handful of realistic fixtures per upstream, and contract checks against the real API kept out of the unit suite.

## Replace the transport, keep the client Code that calls an HTTP API should be tested without the network: tests must be fast and deterministic and must not depend on a third party being up. Guzzle is designed for this. The `Client` delegates the actual sending to its **handler**, so a test swaps the handler and leaves everything else (the base URI, options, middleware and your code's error handling) real. The tool is `GuzzleHttp\Handler\MockHandler`. It holds a **queue**, and each request it receives shifts the next item off: - a `ResponseInterface`, such as `new Response(200, [], '{"EUR":1}')`, fulfils the request; - a `Throwable`, such as `new ConnectException('timeout', new Request('GET', 'rates'))`, rejects it; - a promise is returned as is; - a callable receives the request and options and returns any of the above, useful for responses that depend on the request. `append(...$values)` adds to the queue, `reset()` empties it, `count()` says how many items remain, and `getLastRequest()` and `getLastOptions()` show the most recent call. ## Why the HandlerStack matters Many request options are not implemented by the handler but by middleware around it. `HandlerStack::create($mock)` wraps the mock with the same defaults a real client gets: `http_errors`, `allow_redirects`, `cookies` and `prepare_body`. | Client built with | Queued `new Response(503)` | Queued redirect | |---|---|---| | `['handler' => HandlerStack::create($mock)]` | throws `ServerException`, as in production | followed, if the next queued response is its target | | `['handler' => $mock]` | returned as a normal 503 response | returned as is, not followed | A test built the second way can pass while the production error path is never run. `MockHandler::createWithMiddleware($queue)` is a shortcut that returns `HandlerStack::create(new MockHandler($queue))`. ## Asserting what was sent Returning canned responses is half of a test; the other half is checking the outgoing request. `Middleware::history($container)` records every transaction into an array passed by reference: 1. create `$history = []` and push `Middleware::history($history)` onto the stack; 2. run the code under test; 3. for each entry, inspect `request` (method, URI, headers, body), `response` or `error`, and `options`. That is how you verify that the exchange-rate client sent `GET /v2/latest?base=EUR` with the API key header, or that a retry really sent a second request. ## Scenarios worth covering - **Happy path**: a 200 with a realistic body, then assertions on the parsed result. - **Provider down**: a queued `ConnectException`; the code should fail over or fall back. - **Upstream error**: a 503 through the full stack; the code should see `ServerException`. - **Bad credentials**: a 401; the code should raise a configuration error, not retry. - **Retry behaviour**: queue 503, 503, 200 with the retry middleware pushed; expect success and an empty queue. - **Unexpected extra calls**: a short queue makes any extra call throw `OutOfBoundsException`. ## Making responses realistic A mock is only as good as its fixtures. Copy real response bodies from the provider's documentation or from a recorded call, keep them as files next to the tests, and load them into `new Response(200, ['Content-Type' => 'application/json'], $body)`. Include headers your code reads, such as rate-limit or pagination headers. For request-dependent answers, queue a callable: it receives the request and options, so it can return a different body per currency pair. Keep one or two slow contract tests against the real API elsewhere, outside the unit suite, to notice when the provider changes its format. ## Design for injection All of this requires that the code under test **receives** its client (through the constructor or a factory) rather than calling `new Client()` inside a method. Type-hint the dependency as `GuzzleHttp\ClientInterface`, or as PSR-18's client interface if you only need `sendRequest()`. The test then builds a real `Client` around the mock stack. Mocking the `Client` class with a test-double framework is possible but brittle: it couples the test to which method (`request`, `get`, `send`) the code calls, and skips the middleware whose behaviour you want to verify.

  • What happens when code under test sends one more request than the MockHandler queue holds?
    The mock throws `OutOfBoundsException` with the message 'Mock queue is empty'. That is useful: an unexpected extra call, such as a missing cache or an accidental retry, fails the test loudly instead of passing silently. Check `count()` at the end to catch the opposite case, responses that were queued but never used.
  • How do you assert which URL and query string the code actually sent?
    Push `Middleware::history($container)` onto the handler stack. After the call, each entry in `$container` holds `request`, `response`, `error` and `options`, so you can assert `$container[0]['request']->getUri()` and headers. For a single call, `$mock->getLastRequest()` gives the most recent request directly.

saying these in an interview costs you the question

  • Passing a bare MockHandler as the handler behaves exactly like a real client.
  • MockHandler repeats the last response when the queue runs out.
  • The best way to test Guzzle code is to mock the Client class itself.
  • A queued exception is returned as a response with status 0.
  • Tests should call the real API once to keep responses realistic.