Search by

doppiogancio / mocked-client

doppiogancio

Describe a mocked HTTP server once, then test against it with any client: PSR-18, Guzzle, HTTPlug or Symfony

Package info

github.com/doppiogancio/mocked-client

pkg:composer/doppiogancio/mocked-client

Statistics

Installs: 78 468

Dependents: 4

Suggesters: 0

Stars: 18

Open Issues: 1

v5.0.0 2026-11-05 19:46 UTC

README

Mocked Client

Describe a server. Get any client.

Test the code that talks to an HTTP API, without the HTTP API. You describe the endpoints once, then take the client the code under test expects: PSR-18, Guzzle, HTTPlug or Symfony.

Packagist Version Packagist Downloads PHP Version Build PHPStan Licence

Note

This README documents v5. Coming from v4? Start with the upgrade guide. The v4 documentation lives in the v4.1.4 tag.

// describe the server...
$mock = MockServer::create();
$mock->get('/country/IT')->reply(MockResponse::json(['code' => 'IT', 'name' => 'Italy']));

// ...then take the client your code expects
$client = $mock->guzzle();   // or ->psr18(), ->httplug(), ->symfonyCallback()

That is the whole model, and it is why the class is called MockServer while the package is called mocked client: you describe one server, and it hands out as many clients as you need, all answering from the same stubs and recording to the same journal.

Anything you did not stub fails immediately, with a message that names the request and tells you why every stub refused it. Your tests end up describing exactly the traffic they depend on, and nothing else.

Contents

Why this library

Most HTTP mocks are a queue: you push responses and they come back in order, so your test silently depends on how many calls your code makes and in which sequence. Refactor the code, break the test.

This one is a router. You describe endpoints, the way the real API is described, and the order of calls stops mattering.

Guzzle MockHandler php-http/mock-client Symfony MockHttpClient Mocked Client
How a response is chosen a queue, consumed in call order a queue, consumed in call order a queue, or a callback you write routed by the request
Order of calls matters yes yes with the queue form no
Path placeholders hand rolled hand rolled hand rolled built in
Match on query, headers, JSON body hand rolled hand rolled hand rolled built in
Records requests for assertions via the history middleware yes yes yes
Simulates transport failures yes yes yes yes
Clients it can back Guzzle, and PSR-18 through it PSR-18, HTTPlug Symfony PSR-18, Guzzle, HTTPlug, Symfony

If your project uses one HTTP client and always will, the native mock of that client is a perfectly good answer, and it is one less dependency. This package earns its place when you want to describe an API rather than a call sequence, or when different parts of your codebase reach for different clients.

Install

composer require --dev doppiogancio/mocked-client

You also need a PSR-7 implementation, if your project does not already have one:

composer require --dev nyholm/psr7

Guzzle, HTTPlug and Symfony HttpClient are needed only for their respective adapters. Requires PHP 8.2 or later.

A complete example

A service that fetches a country, and the test that pins its behaviour:

final class CountryApi
{
    public function __construct(private readonly ClientInterface $client) {}

    public function find(string $code): array
    {
        $request = new Request('GET', 'https://api.example.com/country/' . $code);

        return json_decode((string) $this->client->sendRequest($request)->getBody(), true);
    }
}
final class CountryApiTest extends TestCase
{
    public function testItFetchesACountry(): void
    {
        $mock = MockServer::create();
        $mock->get('/country/{code}')->reply(MockResponse::file(__DIR__ . '/fixtures/country.json'));

        $country = (new CountryApi($mock->psr18()))->find('IT');

        $this->assertSame('Italy', $country['name']);
        $mock->assertRequested('GET', '/country/IT');
    }

    public function testItRetriesOnAFlakyConnection(): void
    {
        $mock = MockServer::create();
        $mock->get('/country/{code}')->reply(MockResponse::sequence(
            MockResponse::failure('connection reset'),
            MockResponse::failure('connection reset'),
            MockResponse::json(['code' => 'IT', 'name' => 'Italy']),
        ));

        $country = (new ResilientCountryApi($mock->psr18()))->find('IT');

        $this->assertSame('Italy', $country['name']);
        $mock->assertRequestedTimes(3, 'GET', '/country/IT');
    }
}

Note what the second test does: MockResponse::failure() throws a real NetworkExceptionInterface, the same thing a broken connection produces, so the retry loop is exercised rather than simulated.

How it works

A stub is one matcher chain plus one response. The two are independent, so any matcher combines with any response:

$mock->post('/orders')                          // method and path
     ->whereHeader('authorization', 'Bearer t') // narrow it
     ->whereJson(['sku' => 'ABC'])              // narrow it further
     ->reply(MockResponse::status(201));        // and answer

Stubs are tried in registration order, first match wins. There is no hidden priority: the order you read is the order that applies, so the general case goes last.

$mock->get('/country')->whereQuery(['code' => 'it'])->reply(MockResponse::json($italy));
$mock->get('/country')->whereQuery(['code' => 'de'])->reply(MockResponse::json($germany));
$mock->get('/country')->reply(MockResponse::file(__DIR__ . '/fixtures/countries.json'));

Matching a request

Method Matches when
get() post() put() patch() delete() head() options() on() the method and path match. Paths accept placeholders: /country/{code}, /orders/{id:\d+}
whereQuery(['code' => 'it']) or whereQuery('code=it&page=2') the query string contains at least these parameters
whereHeader('authorization', 'Bearer t') the header has this value. Omit the value to only require its presence
whereJson(['sku' => 'ABC']) the JSON body contains at least these keys, nested ones included
whereCallback(fn (RequestInterface $r) => ...) your callback returns true

Every where*() is a subset check, so a request may carry extra parameters, headers or JSON keys. A test pins the one thing it cares about and stays readable when the payload grows.

The host is ignored, so the same stubs work whether your code calls /country or https://api.example.com/country.

Full reference

Building a response

Factory Produces
MockResponse::text('hi', 201, ['x-a' => 'b']) a plain body
MockResponse::json(['code' => 'IT']) a JSON body, with content-type set for you
MockResponse::file(__DIR__ . '/fixtures/c.json') a body read from a fixture
MockResponse::status(204) a status code and nothing else
MockResponse::psr7($response) a PSR-7 response you already have
MockResponse::using(fn ($request) => ...) a response built from the request
MockResponse::sequence($a, $b, $c) a different response on each consecutive call
MockResponse::failure('timed out') a simulated transport error, for testing retries

Every response is rebuilt for each request, body stream included, so an endpoint called twice hands out two fully readable responses.

Full reference

Checking what was requested

Stubbing says what the server answers. The journal says what your code actually asked:

$mock->assertRequested('POST', '/orders');
$mock->assertRequestedTimes(3, 'GET', '/country/{code}');
$mock->assertNotRequested('DELETE', '/orders/1');
$mock->assertAllStubsUsed();

Assertions accept the same placeholders as stubs, so '/country/{code}' counts every country and '/country/IT' counts only that one. assertAllStubsUsed() catches the opposite problem: a stub written and never exercised, which usually means the test has drifted from the code.

When an assertion is not expressive enough, read the journal:

$sent = $mock->recorded('POST', '/orders')[0];

$sent->jsonBody();                // ['sku' => 'ABC', 'qty' => 2]
$sent->header('content-type');    // 'application/json'
$sent->response?->getStatusCode();

Full reference

Choosing a client

One server, every flavour. They all share the same stubs and the same journal, so you can hand a Guzzle client to one collaborator and a PSR-18 client to another in the same test.

$mock->psr18();          // Psr\Http\Client\ClientInterface
$mock->guzzle();         // GuzzleHttp\Client, with optional middlewares and client options
$mock->guzzleHandler();  // a handler for a HandlerStack you build yourself
$mock->httplug();        // Http\Client\HttpAsyncClient
$mock->handle($request); // PSR-7 in, PSR-7 out, no client at all

// Symfony's own MockHttpClient does the response building
new Symfony\Component\HttpClient\MockHttpClient($mock->symfonyCallback());

Every exception raised while answering a request implements Psr\Http\Client\ClientExceptionInterface, as PSR-18 requires, so catch (ClientExceptionInterface $e) in the code under test behaves exactly as it would against a real client.

Full reference

Failing fast

You do not have to know which endpoints your code calls. Write no stubs, run the test, and read the failure:

No stub matched POST /orders

Request as received:
  POST /orders?dry=1
  content-type: application/json
  {"sku":"ABC","qty":2}

3 stub(s) registered:
  GET  /orders       method is GET, not POST
  POST /orders       query is missing "dry"
  POST /orders/{id}  path does not match "/orders/{id}"

It prints the request as received and, for each stub, the precise reason it refused. Add the stub it asks for and repeat, so you only ever write the stubs you actually need.

Documentation

1. Matching requests placeholders, query, headers, JSON body, callbacks
2. Building responses strings, JSON, fixtures, PSR-7, dynamic responses
3. Sequences and failures consecutive calls, simulated transport errors
4. Asserting requests the journal and the assertions
5. Clients and adapters PSR-18, Guzzle, HTTPlug, Symfony, containers
6. Recipes retries, pagination, auth tokens, error handling
7. Upgrading from v4 the v5 migration table

Contributing

Issues and pull requests are welcome. Before opening one:

composer code-review   # fixes the coding standard, then runs PHPStan and the tests

The suite must stay green on every supported PHP version, PHPStan runs at level 8, and the coding standard is Doctrine. Examples printed in the documentation are executed in CI, so they cannot drift from the code.

Licence

MIT. See LICENCE.md.

Built by Fabrizio Gargiulo.