Testing PHP HTTP Clients with Guzzle Mocks
When your PHP application talks to external APIs — payment gateways, shipping providers, weather services, anything — your tests shouldn't actually call those APIs. Real HTTP calls are slow, flaky, rate-limited, and costly. Worse, they make your test suite dependent on external uptime.
Guzzle, the most widely used PHP HTTP client, ships with a MockHandler that lets you stub any HTTP response without touching the network. This guide covers the full toolkit: MockHandler, response queues, request history, and testing error scenarios.
The MockHandler Setup
Guzzle's testing utilities live in GuzzleHttp\Handler\MockHandler and GuzzleHttp\HandlerStack. You compose them into a mock client:
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
$mock = new MockHandler([
new Response(200, [], json_encode(['status' => 'ok', 'id' => 42])),
]);
$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);Every request made through this $client will consume the next response from the queue instead of making a real HTTP call.
Injecting the Mock Client
For this to work in tests, your service must accept the HTTP client as a dependency — don't construct it internally:
class ShippingService
{
public function __construct(private readonly ClientInterface $client) {}
public function createShipment(array $order): ShipmentResult
{
$response = $this->client->post('/shipments', [
'json' => [
'address' => $order['shipping_address'],
'weight_grams' => $order['weight'],
],
]);
$data = json_decode($response->getBody()->getContents(), true);
return new ShipmentResult(
trackingNumber: $data['tracking_number'],
estimatedDelivery: new \DateTimeImmutable($data['estimated_delivery']),
labelUrl: $data['label_url'],
);
}
}Now in tests, you inject the mock client instead of the real one.
Testing the Happy Path
class ShippingServiceTest extends \PHPUnit\Framework\TestCase
{
private function makeClient(array $responses): Client
{
$mock = new MockHandler($responses);
return new Client(['handler' => HandlerStack::create($mock)]);
}
public function testCreateShipmentReturnsTrackingNumber(): void
{
$responseBody = json_encode([
'tracking_number' => '1Z999AA10123456784',
'estimated_delivery' => '2026-05-28',
'label_url' => 'https://shipping.example.com/labels/abc123.pdf',
]);
$client = $this->makeClient([new Response(200, [], $responseBody)]);
$service = new ShippingService($client);
$result = $service->createShipment([
'shipping_address' => '123 Main St, Springfield',
'weight' => 500,
]);
$this->assertSame('1Z999AA10123456784', $result->trackingNumber);
$this->assertSame('2026-05-28', $result->estimatedDelivery->format('Y-m-d'));
$this->assertStringContainsString('labels/abc123.pdf', $result->labelUrl);
}
}Testing Error Responses
This is where mock testing really pays off — testing how your code handles 4xx/5xx responses is trivial with MockHandler:
use GuzzleHttp\Exception\ClientException;
public function testCreateShipmentThrowsOnAddressValidationError(): void
{
$errorBody = json_encode([
'error' => 'address_invalid',
'message' => 'Address could not be validated',
]);
$client = $this->makeClient([
new Response(422, [], $errorBody),
]);
$service = new ShippingService($client);
$this->expectException(InvalidAddressException::class);
$this->expectExceptionMessage('Address could not be validated');
$service->createShipment([
'shipping_address' => 'not a real address',
'weight' => 500,
]);
}
public function testCreateShipmentThrowsOnServiceUnavailable(): void
{
$client = $this->makeClient([
new Response(503, [], 'Service Unavailable'),
]);
$service = new ShippingService($client);
$this->expectException(ShippingServiceUnavailableException::class);
$service->createShipment(['shipping_address' => '123 Main St', 'weight' => 500]);
}Testing Network Failures
Beyond HTTP errors, you need to test what happens when the connection fails entirely — timeouts, DNS failures, connection refused:
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
public function testCreateShipmentThrowsOnTimeout(): void
{
$mock = new MockHandler([
new ConnectException('Connection timed out', new Request('POST', '/shipments')),
]);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$service = new ShippingService($client);
$this->expectException(ShippingNetworkException::class);
$service->createShipment(['shipping_address' => '123 Main St', 'weight' => 500]);
}MockHandler accepts any Throwable in the queue, not just Response objects.
Verifying Request Contents with History Middleware
Sometimes you need to assert not just that a response was handled correctly, but that the right request was sent. Use Guzzle's history middleware:
use GuzzleHttp\Middleware;
public function testCreateShipmentSendsCorrectPayload(): void
{
$container = [];
$history = Middleware::history($container);
$mock = new MockHandler([
new Response(200, [], json_encode([
'tracking_number' => 'TRACK123',
'estimated_delivery' => '2026-05-28',
'label_url' => 'https://example.com/label.pdf',
])),
]);
$stack = HandlerStack::create($mock);
$stack->push($history);
$client = new Client(['handler' => $stack]);
$service = new ShippingService($client);
$service->createShipment([
'shipping_address' => '123 Main St',
'weight' => 750,
]);
$this->assertCount(1, $container);
$sentRequest = $container[0]['request'];
$body = json_decode($sentRequest->getBody()->getContents(), true);
$this->assertSame('POST', $sentRequest->getMethod());
$this->assertSame('/shipments', $sentRequest->getUri()->getPath());
$this->assertSame('123 Main St', $body['address']);
$this->assertSame(750, $body['weight_grams']);
}This verifies the serialization logic — not just that the service handles responses, but that it sends the right data upstream.
Queuing Multiple Responses
For retry logic or multi-step flows, queue several responses:
public function testServiceRetriesOnce503ThenSucceeds(): void
{
$client = $this->makeClient([
new Response(503, [], 'Service Unavailable'), // first attempt fails
new Response(200, [], json_encode([ // retry succeeds
'tracking_number' => 'RETRY123',
'estimated_delivery' => '2026-05-29',
'label_url' => 'https://example.com/label2.pdf',
])),
]);
$service = new ShippingService($client);
$result = $service->createShipment(['shipping_address' => '123 Main St', 'weight' => 500]);
$this->assertSame('RETRY123', $result->trackingNumber);
}MockHandler returns responses in order. If your service consumes more responses than are queued, it throws a OutOfBoundsException — which also serves as a useful assertion that you're not making unexpected extra requests.
Mocking external APIs makes your unit tests reliable and fast. For end-to-end confidence that your real API integrations keep working in production, HelpMeTest provides 24/7 test monitoring — sign up free and catch broken integrations before your users do.