Skip to main content
WritingVideosSpeakingCodeAbout

SDK Toolbox docs

Testing

PHP ^8.316 pages

Docs for v0.0.1, from GitHub.

Pages in these docs

Everything in JustSteveKing\SdkToolbox\Testing is for an SDK’s own test suite, and for the test suites of applications that use it.

The fake client

FakeClient is a PSR-18 client that answers from a queue, in order, and records what it was sent:

$http = new FakeClient(
FakeResponse::json(['id' => 'prj_1', 'name' => 'Website']),
FakeResponse::problem(404, title: 'Not Found', detail: 'No project prj_9.'),
FakeResponse::networkError('Could not resolve host'),
);
$acme = new Acme('test-token', $http);
ResponseGives
FakeResponse::json($data, $status, $headers)a JSON response
FakeResponse::problem($status, type:, title:, detail:, extensions:)an RFC 9457 application/problem+json response
FakeResponse::text($body, $status, $headers)plain text
FakeResponse::empty($status, $headers)no body, 204 by default
FakeResponse::networkError($message)a PSR-18 network failure, thrown when reached

A closure works too: fn (RequestInterface $request) => ... answers based on what was sent. push() adds more answers later.

When the queue runs out, the next request throws StrayRequestException. It deliberately isn’t a PSR-18 exception: the connector would wrap that as a transport error, and an SDK might retry past it, so a test could pass for the wrong reason.

Assertions

AssertionPasses when
assertSent(fn (RequestInterface $r) => ...)some request matches
assertNotSent(fn ...)no request matches
assertSentTo('POST', '/v2/projects')a request with that method and path went
assertSentJson(['meta' => ['ref' => 'x']])a JSON body contained all of that (a subset match)
assertSentInOrder(['POST /v2/charges', 'GET /v2/charges/ch_1'])those went in that order, others allowed between
assertSentCount(2), assertNothingSent()that many were sent
assertQueueEmpty()every queued answer was used

They report through PHPUnit (and so Pest) when it’s installed, and throw AssertionError otherwise. sent() and last() give you the PSR-7 requests themselves.

Retries in tests

Retries are on by default, so a test that queues one 503 for a GET will see the request sent again. Either queue the answers a retry will use, or build the connector under test with retry: RetryPolicy::none() when the test is about a single answer.

Time

Retries, the throttle, rate-limit resets, OAuth expiry, webhook tolerance and the device flow all ask a PSR-20 clock what time it is, and a Sleeper to wait. FakeTime is both, and sleeping just moves its clock on:

$time = new FakeTime('2026-11-16 09:00:00 UTC');
$acme = new Connector('https://api.acme.test', http: $http, clock: $time, sleeper: $time);
$acme->send(new GetProject('prj_1')); // a 503 with Retry-After: 2, then a 200
expect($time->sleeps())->toBe([2.0]);

advance($seconds) moves the clock without a sleep, for expiring tokens or cache entries. ArrayCache is an in-memory PSR-16 cache that runs on the same clock, for testing the HTTP cache, token stores and rate-limit stores.

Recorded fixtures

Hand-written fake responses drift from the real API. Recorder records real responses once and replays them after:

$http = new Recorder(__DIR__.'/fixtures', 'projects/list', RecordMode::fromEnvironment());
$acme = new Acme(getenv('ACME_TOKEN') ?: 'not-needed-for-replay', $http);
ModeDoes
RecordMode::Replay (the default)answers from the fixture; a missing fixture fails the test
RecordMode::Recordsends to the real API and writes the fixture afresh
RecordMode::Missingreplays what exists, records what doesn’t

fromEnvironment() reads SDK_FIXTURES, so SDK_FIXTURES=record vendor/bin/pest re-records, and CI, with nothing set, replays.

  • Safe to commit. Fixtures are JSON, written with credentials already masked: sensitive headers and query parameters, plus secret-looking keys in JSON and form bodies (access_token, refresh_token, client_secret, password, api_key and others) in both directions.
  • Safe to name. A fixture name is letters, digits, -, _ and /, and can’t resolve outside the directory. That’s the path traversal Saloon fixed in v4.
  • Strict on replay. Requests have to arrive in the recorded order, with the recorded method and path. Anything else throws FixtureMismatchException, saying what differed, rather than quietly answering the wrong request.

Deprecated endpoints

Build the connector in your SDK’s tests with deprecations: Deprecations::strict(). When the API starts marking an endpoint deprecated, a test that hits it fails, and you hear about it before your users do.

Type assertions

The toolbox’s generics are what make send(new GetProject($id)) a Project to PHPStan. A file of PHPStan type assertions keeps that true as your SDK changes:

use function PHPStan\Testing\assertType;
$acme = new Acme('token', new FakeClient);
assertType(Project::class, $acme->send(new GetProject('prj_1')));
assertType('list<'.Member::class.'>', $acme->send(new ListMembers));
assertType('JustSteveKing\SdkToolbox\Pagination\Paginator<'.Member::class.'>', $acme->paginate(new ListMembers, new LinkPagination));

Put it in a directory PHPStan analyses but your test runner doesn’t. A request whose @implements Request<...> is wrong then fails phpstan analyse.