Testing PHP HTTP Clients with Guzzle Mocks

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.

Read more

Start now free