A full HTTP request simulation framework for Hibla HTTP Client. Mock responses, simulate network conditions, record every request, and assert on exactly what your application sent, without changing a single line of production code.
Whether you are unit testing a service in isolation, running integration tests against a staging environment, or simulating catastrophic network failures, the testing plugin gives you the tools to do it cleanly and expressively in both Pest and PHPUnit.
Http::startTesting();
Http::mock('POST')
->url('https://api.example.com/orders')
->expectHeader('Authorization', 'Bearer secret')
->expectJson(['item' => 'book', 'qty' => 2])
->respondWithStatus(201)
->respondJson(['id' => 'ord-001', 'status' => 'confirmed'])
->register();
$order = $service->placeOrder(item: 'book', qty: 2);
expect($order->status)->toBe('confirmed');
Http::assertRequestMade('POST', 'https://api.example.com/orders');
Http::assertBearerTokenSent('secret');
Http::assertRequestJsonContains('POST', 'https://api.example.com/orders', ['item' => 'book']);
Http::stopTesting();- Requirements
- Installation
- Getting Started
- Mocking Requests
- Matching Request Expectations
- Simulating Failures
- Retry Sequences
- File Operations
- Cookie Mocking
- Server-Sent Events
- Network Simulation
- Passthrough Mode
- Assertions
- Inspecting Recorded Requests
- Debugging
- API Reference
- Full Test Examples
- Development
- Credits
- License
- PHP 8.4 or higher
hiblaphp/http-client
This package is currently in beta. Before installing, ensure your
composer.jsonallows beta releases:
{
"minimum-stability": "beta",
"prefer-stable": true
}composer require hiblaphp/http-client-testingRequirements:
- PHP 8.4+
No additional service providers, configuration files, or boot steps are required. The plugin integrates with the HTTP client automatically once Http::startTesting() is called if using the Http facade.
Http::startTesting() swaps the real cURL handler for the testing handler globally. Every Http:: call your application makes is intercepted automatically, with no changes to application code required.
use Hibla\HttpClient\Http;
// Pest
beforeEach(fn () => Http::startTesting());
afterEach(fn () => Http::stopTesting());
// PHPUnit
protected function setUp(): void { Http::startTesting(); }
protected function tearDown(): void { Http::stopTesting(); }Use Http::resetTesting() between tests when you want to clear recorded requests and mocks without fully disabling testing mode:
// Pest — useful in a single describe block with many cases
afterEach(fn () => Http::resetTesting());If your application wires HTTP clients through a service container or constructor injection, use TestingHttpHandler directly. Because it extends HttpHandler, it can be swapped in anywhere a real handler is expected.
use Hibla\HttpClient\HttpClient;
use Hibla\HttpClient\Testing\TestingHttpHandler;
$handler = new TestingHttpHandler();
$client = new HttpClient()->withHandler($handler);
// Inject $client into the service under test
$service = new UserService($client);Configure mocks and run assertions on the handler instance:
$handler->mock('GET')
->url('https://api.example.com/users')
->respondJson(['users' => []])
->register();
$service->listUsers();
$handler->assertRequestMade('GET', 'https://api.example.com/users');
$handler->assertBearerTokenSent('my-token');With Pest:
use Hibla\HttpClient\HttpClient;
use Hibla\HttpClient\Testing\TestingHttpHandler;
beforeEach(function () {
$this->handler = new TestingHttpHandler();
$this->client = new HttpClient()->withHandler($this->handler);
$this->service = new UserService($this->client);
});
afterEach(fn () => $this->handler->reset());
it('fetches users', function () {
$this->handler->mock('GET')
->url('https://api.example.com/users')
->respondJson(['users' => [['id' => 1]]])
->register();
$users = $this->service->listUsers();
expect($users)->toHaveCount(1);
$this->handler->assertRequestMade('GET', 'https://api.example.com/users');
});Facade (Http::) |
Direct DI | |
|---|---|---|
| Setup effort | Minimal | Requires wiring |
Works with static Http:: calls |
Yes | Only injected client |
| Works with DI containers | Yes | Yes |
| Multiple independent handlers | No | Yes |
| Isolates only one service | No | Yes |
All mock builder methods and every assertion available on Http:: are equally available on the TestingHttpHandler instance. There is no difference in capability between the two approaches.
All mocking is done through Http::mock() (or $handler->mock() in DI mode), which returns a fluent MockRequestBuilder. You must call ->register() at the end of every chain to activate the mock. A builder that is not registered has no effect and will not intercept any requests.
Http::mock('GET')
->url('https://api.example.com/users')
->respondWithStatus(200)
->respondJson(['users' => []])
->register();Http::mock('GET')->url('...')->respondJson([...])->register();
Http::mock('POST')->url('...')->respondWithStatus(201)->register();
Http::mock('PUT')->url('...')->respondWithStatus(200)->register();
Http::mock('PATCH')->url('...')->respondWithStatus(200)->register();
Http::mock('DELETE')->url('...')->respondWithStatus(204)->register();
// Match any HTTP method
Http::mock('*')->url('https://api.example.com/*')->respondWithStatus(200)->register();URL patterns support fnmatch wildcards. Trailing slashes are handled leniently; a pattern without one matches URLs with or without.
Http::mock('GET')->url('https://api.example.com/users/*')->respondJson([...])->register();
Http::mock('GET')->url('https://api.example.com/*/profile')->respondJson([...])->register();
Http::mock('GET')->url('https://*.example.com/*')->respondJson([...])->register();// Plain string
Http::mock()->url('...')->respondWith('hello world')->register();
// JSON — automatically sets Content-Type: application/json
Http::mock()->url('...')->respondJson(['id' => 1, 'name' => 'Alice'])->register();
// XML — automatically sets Content-Type: application/xml
Http::mock()->url('...')->respondXml('<user><name>Alice</name></user>')->register();
// Status only
Http::mock()->url('...')->respondWithStatus(204)->register();
// Status with body
Http::mock()->url('...')->respondWithStatus(422)->respondJson(['error' => 'Invalid input'])->register();Http::mock()
->url('...')
->respondWithStatus(200)
->respondWithHeader('X-Request-Id', 'abc-123')
->respondWithHeaders([
'Cache-Control' => 'no-cache',
'X-RateLimit-Remaining' => '99',
])
->respondJson([...])
->register();Http::mock('GET')
->url('https://api.example.com/stream')
->respondWithChunks([
'{"type":"start"}' . "\n",
'{"type":"data","value":1}' . "\n",
'{"type":"data","value":2}' . "\n",
'{"type":"end"}' . "\n",
])
->register();By default a mock is consumed after being matched once. Use ->persistent() to reuse it across multiple requests:
Http::mock('GET')
->url('https://api.example.com/health')
->respondWithStatus(200)
->respondJson(['status' => 'ok'])
->persistent()
->register();All latency methods are non-blocking. Delays are applied asynchronously, so concurrent requests are interleaved rather than queuing behind one another. This lets you simulate realistic concurrent workloads without artificially serialising your test requests.
// Fixed delay before responding
Http::mock()->url('...')->latency(0.5)->respondJson([...])->register();
// Slow response alias
Http::mock()->url('...')->slowResponse(2.0)->respondJson([...])->register();
// Random delay chosen once at registration time
Http::mock()->url('...')->randomLatency(0.1, 0.8)->respondJson([...])->register();
// Persistent mock: fresh random delay on every match
Http::mock()->url('...')->randomPersistentLatency(0.05, 0.3)->respondJson([...])->register();
// Per-chunk latency for downloads, streams, and SSE (seconds per 8KB chunk)
// jitter adds a random ±percentage variation to each chunk delay, simulating an unsteady connection
Http::mock()->url('...')->downloadLargeFile(sizeInKB: 512)->dataStreamTransferLatency(0.05, jitter: 0.2)->register();Constraints make a mock only match requests that satisfy specific criteria. Unmatched mocks remain in the queue.
Http::mock('POST')
->url('https://api.example.com/orders')
->expectHeader('Authorization', 'Bearer my-token')
->expectHeader('X-Tenant-Id', 'acme')
->respondWithStatus(201)
->register();
// Multiple headers at once
Http::mock('POST')
->url('...')
->expectHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json',
])
->respondWithStatus(200)
->register();Http::mock('POST')
->url('https://api.example.com/users')
->expectJson(['name' => 'Alice', 'role' => 'admin'])
->respondWithStatus(201)
->register();Http::mock('POST')
->url('...')
->expectBody('*"action":"checkout"*')
->respondWithStatus(200)
->register();Http::mock('GET')
->url('...')
->expectCookies(['session' => 'abc123', 'theme' => 'dark'])
->respondJson([...])
->register();use Hibla\HttpClient\Testing\Utilities\RecordedRequest;
Http::mock('POST')
->url('...')
->expect(function (RecordedRequest $request): bool {
$json = $request->getJson();
return isset($json['amount']) && $json['amount'] > 0;
})
->respondWithStatus(200)
->register();Http::mock()->url('...')->fail('Service unavailable')->register();Http::mock()->url('...')->timeout(30.0)->register();
// Retryable timeout
Http::mock()->url('...')->timeoutFailure(timeoutAfter: 5.0)->register();Http::mock()->url('...')->networkError('connection')->register(); // Connection failed
Http::mock()->url('...')->networkError('timeout')->register(); // Connection timed out
Http::mock()->url('...')->networkError('resolve')->register(); // DNS failure
Http::mock()->url('...')->networkError('ssl')->register(); // SSL handshake failureHttp::mock()->url('...')->retryableFailure('Connection reset by peer')->register();These helpers automatically register a series of mocks simulating failures before eventual success, making it straightforward to test retry logic without manually registering each mock.
Http::mock('POST')
->url('https://api.example.com/submit')
->failUntilAttempt(3) // fails on attempts 1 and 2, succeeds on 3
->register();Http::mock()->url('...')->timeoutUntilAttempt(3, timeoutAfter: 5.0)->register();Http::mock()->url('...')->statusFailuresUntilAttempt(4, failureStatus: 503)->register();Http::mock()
->url('...')
->failWithSequence(
failures: [
'Connection refused',
['error' => 'Gateway timeout', 'retryable' => true, 'delay' => 0.2],
['status' => 429],
],
successResponse: ['ok' => true]
)
->register();// Cycles through timeout, connection, DNS, SSL failures until success
Http::mock()->url('...')->mixedFailuresUntilAttempt(5)->register();
// Explicit pattern — true = fail, false = succeed
Http::mock()->url('...')->intermittentFailures([true, false, true, true, false])->register();// Returns 429 with Retry-After on attempts 1–3, 200 on attempt 4
Http::mock()->url('...')->rateLimitedUntilAttempt(4)->register();// Simulates network recovery: severe delays early, success eventually
Http::mock()->url('...')->slowlyImproveUntilAttempt(5, maxDelay: 10.0)->register();Http::mock('GET')
->url('https://files.example.com/report.pdf')
->downloadFile(
content: $pdfContent,
filename: 'report.pdf',
contentType: 'application/pdf'
)
->register();Large file simulation with generated content:
Http::mock('GET')
->url('...')
->downloadLargeFile(sizeInKB: 512, filename: 'archive.zip')
->register();Slow transfer with per-chunk latency:
Http::mock('GET')
->url('...')
->downloadLargeFile(sizeInKB: 1024)
->dataStreamTransferLatency(seconds: 0.05, jitter: 0.2) // ~50ms ± 20% per 8KB chunk
->register();Mock the endpoint that receives the upload. The testing handler records the upload source path for assertion:
Http::mock('PUT')
->url('https://storage.example.com/uploads/*')
->respondWithStatus(200)
->respondJson(['uploaded' => true])
->register();Use ->setCookie() or ->setCookies() to add Set-Cookie headers to a mock response. The handler parses and stores them in the cookie jar automatically, exactly as a real response would:
Http::mock('POST')
->url('https://auth.example.com/login')
->respondWithStatus(200)
->setCookie(
name: 'session',
value: 'tok-abc123',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Strict',
expires: time() + 3600
)
->register();
// Multiple cookies at once
Http::mock('GET')
->url('...')
->setCookies([
'theme' => ['value' => 'dark', 'path' => '/'],
'lang' => ['value' => 'en', 'secure' => true],
])
->register();Important: Every SSE mock must use one of the SSE builder methods such as
respondWithSSE(),sseWithEventIds(),sseInfiniteStream(), etc. To register itself as an SSE response. UsingrespondWith()orrespondJson()alone on a mock matched byHttp::sse()will cause a runtime error. All SSE builder methods callasSSE()internally.
Http::mock('GET')
->url('https://api.example.com/events')
->respondWithSSE([
['event' => 'connected', 'data' => '{"status":"ready"}', 'id' => '1'],
['event' => 'update', 'data' => '{"value":42}', 'id' => '2'],
['event' => 'done', 'data' => ''],
])
->register();Http::mock('GET')
->url('...')
->respondWithSSE([]) // initialise as SSE
->addSSEEvent(data: '{"status":"ready"}', event: 'connected', id: '1')
->addSSEEvent(data: '{"value":42}', event: 'update', id: '2')
->register();Http::mock('GET')
->url('...')
->sseWithRetry(events: [['data' => '{"connected":true}']], retryMs: 5000)
->register();Http::mock('GET')
->url('...')
->sseWithRetryDirective(retryMs: 3000, events: [['data' => '{"connected":true}']])
->register();Http::mock('GET')
->url('...')
->sseWithEventIds([
['id' => '1', 'data' => '{"msg":"first"}'],
['id' => '2', 'data' => '{"msg":"second"}'],
['id' => '3', 'data' => '{"msg":"third"}'],
])
->register();Http::mock('GET')
->url('...')
->sseWithKeepalive(
dataEvents: [
['data' => '{"status":"processing"}'],
['data' => '{"status":"done"}'],
],
keepaliveCount: 5
)
->register();Http::mock('GET')
->url('...')
->sseHeartbeatOnly(heartbeatCount: 10)
->register();Http::mock('GET')
->url('...')
->sseMultipleTypes([
'price_update' => ['{"symbol":"BTC","price":60000}', '{"symbol":"ETH","price":3000}'],
'alert' => [['level' => 'info', 'msg' => 'Market open']],
])
->register();Http::mock('GET')
->url('...')
->sseWithComments(
events: [['data' => '{"payload":1}'], ['data' => '{"payload":2}']],
comments: ['keep-alive', 'heartbeat']
)
->register();Matches only a reconnection request that carries the specified Last-Event-ID header:
Http::mock('GET')
->url('...')
->sseExpectLastEventId('42', eventsAfterResume: [
['id' => '43', 'data' => '{"msg":"resumed"}'],
['id' => '44', 'data' => '{"msg":"continued"}'],
])
->register();Http::mock('GET')
->url('...')
->sseDisconnectAfter(eventsBeforeDisconnect: 3, disconnectError: 'Connection reset')
->register();For realistic time-based streams the periodic emitter sends events one by one on a timer. Control the interval between events using ->dataStreamTransferLatency().
Http::mock('GET')
->url('...')
->sseWithPeriodicEvents([
['event' => 'tick', 'data' => '{"n":1}', 'id' => '1'],
['event' => 'tick', 'data' => '{"n":2}', 'id' => '2'],
['event' => 'tick', 'data' => '{"n":3}', 'id' => '3'],
])
->dataStreamTransferLatency(0.1) // 100ms between events
->register();Http::mock('GET')
->url('...')
->sseWithLimitedEvents(
eventCount: 10,
eventGenerator: fn (int $i) => [
'data' => json_encode(['index' => $i, 'ts' => time()]),
'id' => (string) $i,
'event' => 'message',
]
)
->dataStreamTransferLatency(0.05)
->register();Runs until the client cancels. Optionally capped with maxEvents:
Http::mock('GET')
->url('...')
->sseInfiniteStream(
eventGenerator: fn (int $i) => [
'event' => 'price',
'data' => json_encode(['value' => rand(100, 999)]),
'id' => (string) $i,
],
maxEvents: 100
)
->dataStreamTransferLatency(0.2)
->register();Http::mock('GET')
->url('...')
->ssePeriodicThenDisconnect(
eventCount: 5,
disconnectError: 'Connection lost',
eventGenerator: fn (int $i) => [
'data' => json_encode(['index' => $i]),
'id' => (string) $i,
]
)
->dataStreamTransferLatency(0.1)
->register();These helpers simulate connection instability for testing SSE reconnection logic. All of them register the mock as an SSE response internally, so you do not need to call respondWithSSE() additionally.
Http::mock('GET')
->url('...')
->sseFailUntilAttempt(
successAttempt: 3,
successEvents: [['event' => 'connected', 'data' => '{}']],
failureError: 'Connection refused'
)
->register();Http::mock('GET')
->url('...')
->sseTimeoutUntilAttempt(successAttempt: 3, timeoutAfter: 5.0)
->register();Http::mock('GET')
->url('...')
->sseFailWithSequence(
failures: [
'Connection refused',
['error' => 'SSL handshake failed', 'retryable' => true, 'delay' => 0.2],
],
successEvents: [['data' => '{"ready":true}']]
)
->register();Http::mock('GET')
->url('...')
->sseDropAfterEvents(
eventsBeforeDrop: [
['id' => '1', 'data' => '{"msg":"first"}'],
['id' => '2', 'data' => '{"msg":"second"}'],
],
dropError: 'Connection lost'
)
->register();Http::mock('GET')
->url('...')
->sseReconnectFromEventId(
lastEventId: '2',
eventsAfterResume: [
['id' => '3', 'data' => '{"msg":"resumed"}'],
['id' => '4', 'data' => '{"msg":"continued"}'],
]
)
->register();Http::mock('GET')
->url('...')
->sseRateLimitedUntilAttempt(successAttempt: 4)
->register();// Cycles: Connection refused → Connection reset → Timed out → success
Http::mock('GET')
->url('...')
->sseNetworkErrorsUntilAttempt(successAttempt: 4)
->register();Http::mock('GET')
->url('...')
->sseSlowlyImproveUntilAttempt(successAttempt: 5, maxDelay: 10.0)
->register();// Cycles: timeout → connection error → DNS → SSL → success
Http::mock('GET')
->url('...')
->sseMixedFailuresUntilAttempt(successAttempt: 5)
->register();// true = fail, false = succeed
Http::mock('GET')
->url('...')
->sseIntermittentFailures([true, false, true, true, false])
->register();The testing handler can inject realistic network conditions globally across all requests, layered on top of per-mock delays.
// Presets
Http::startTesting()->withFastNetwork(); // sub-100ms, ~0% failure
Http::startTesting()->withMobileNetwork(); // 0.5–3s latency, 8% failure rate
Http::startTesting()->withPoorNetwork(); // 1–5s latency, 15% failure rate
Http::startTesting()->withUnstableNetwork(); // high variability, 20% failure rate
// Custom configuration
Http::startTesting()->enableNetworkSimulation([
'random_delay' => [0.2, 1.5], // seconds
'failure_rate' => 0.05, // 5% of requests fail outright
'timeout_rate' => 0.03, // 3% time out
'connection_failure_rate' => 0.02, // 2% connection refused
'retryable_failure_rate' => 0.08, // 8% retryable errors
]);
// Global random latency applied on top of per-mock delays
Http::startTesting()->withGlobalRandomLatencey(minSeconds: 0.05, maxSeconds: 0.3);
// Remove global random latency
Http::getTestingHandler()->withoutGlobalrandomLatency();
// Disable all simulation
Http::getTestingHandler()->disableNetworkSimulation();By default the testing handler throws an UnexpectedRequestException whenever a request is made that does not match any registered mock. This prevents tests from silently hitting a real network.
In some scenarios you may want to mock only specific endpoints while letting everything else go through to the real network. enablePassthrough() enables this:
Http::startTesting()->enablePassthrough();Or in DI mode:
$handler = new TestingHttpHandler();
$handler->enablePassthrough();
$client = new HttpClient()->withHandler($handler);When passthrough is enabled the handler resolves requests in this order:
- Check registered mocks, if one matches, return the mock response as normal.
- If no mock matches, forward the request to the real network using the underlying cURL handler.
The mock queue is consumed exactly as it would be without passthrough. A non-persistent mock is removed after being matched, and a persistent mock stays. Only genuinely unmatched requests reach the network.
Real passthrough requests are recorded to history just like mocked ones. Every assertion and inspection method works on them without any special handling:
Http::startTesting()->enablePassthrough();
// No mock registered — goes to the real network
$response = await Http::get('https://api.example.com/users');
// Still recorded and assertable
Http::assertRequestMade('GET', 'https://api.example.com/users');
Http::assertHeaderSent('Accept', 'application/json');
Http::assertRequestCount(1);
$last = Http::getLastRequest();
echo $last->getUrl(); // https://api.example.com/users
echo $last->getMethod(); // GETHttp::startTesting()->enablePassthrough();
// Mocked — no real request made
Http::mock('POST')
->url('https://mailer.example.com/send')
->respondWithStatus(200)
->register();
// Real network request
$users = await Http::get('https://api.staging.example.com/users');
// Mocked response
$mail = await Http::post('https://mailer.example.com/send', ['to' => '[email protected]']);
// Both are in history
Http::assertRequestCount(2);
Http::assertRequestMade('GET', 'https://api.staging.example.com/users');
Http::assertRequestMade('POST', 'https://mailer.example.com/send');Http::getTestingHandler()->disablePassthrough();Passthrough makes tests non-deterministic. Real network calls depend on external services being available, which can cause intermittent failures in CI. Use passthrough deliberately — for integration or contract tests — rather than as a default.
Real requests are not retried by the mock handler. Retry logic is handled by the real HttpHandler pipeline, the same as in production.
Passthrough is disabled by default. You must explicitly call enablePassthrough(). This ensures accidentally unmatched mocks always fail loudly in unit tests.
throwOnUnexpected is automatically disabled when you call enablePassthrough(), since throwing on an unmatched request would contradict letting it through. Calling disablePassthrough() re-enables it.
All assertion methods are available as static calls on Http:: or directly on the TestingHttpHandler instance when using DI.
Http::assertRequestMade('POST', 'https://api.example.com/users');
Http::assertNoRequestsMade();
Http::assertRequestCount(3);
Http::assertRequestNotMade('DELETE', 'https://api.example.com/users/1');
Http::assertSingleRequestTo('https://api.example.com/login');
Http::assertRequestCountTo('https://api.example.com/search', maxCount: 5);
// URL pattern (fnmatch)
Http::assertRequestMatchingUrl('GET', 'https://api.example.com/users/*');
// Ordered sequence
Http::assertRequestSequence([
['method' => 'POST', 'url' => 'https://auth.example.com/login'],
['method' => 'GET', 'url' => 'https://api.example.com/users'],
]);
// By position in history
Http::assertRequestAtIndex('GET', 'https://api.example.com/users', index: 0);All header assertions accept an optional $requestIndex parameter to target a specific request in history.
Http::assertHeaderSent('Authorization');
Http::assertHeaderSent('Authorization', 'Bearer my-token');
Http::assertHeaderSent('X-Tenant-Id', 'acme', requestIndex: 1); // second request
Http::assertHeaderNotSent('X-Debug');
Http::assertHeadersSent(['Content-Type' => 'application/json', 'Accept' => 'application/json']);
Http::assertHeaderMatches('Authorization', '/^Bearer [a-z0-9\-]+$/i');
Http::assertBearerTokenSent('my-token');
Http::assertBearerTokenSent('my-token', requestIndex: 0);
Http::assertContentType('application/json');
Http::assertAcceptHeader('application/json');
Http::assertUserAgent('MyApp/1.0');Http::assertRequestWithBody('POST', 'https://api.example.com/submit', '{"action":"go"}');
Http::assertRequestBodyContains('POST', 'https://api.example.com/submit', '"action"');
Http::assertRequestBodyMatches('POST', '...', '/^\{"action":\s*"[a-z]+"\}$/');
Http::assertRequestWithEmptyBody('GET', 'https://api.example.com/ping');
Http::assertRequestHasBody('POST', 'https://api.example.com/data');
Http::assertRequestIsJson('POST', 'https://api.example.com/data');
Http::assertRequestWithJson('POST', 'https://api.example.com/users', [
'name' => 'Alice',
'role' => 'admin',
]);
Http::assertRequestJsonContains('POST', 'https://api.example.com/users', [
'role' => 'admin',
]);
// Dot-notation path
Http::assertRequestJsonPath('POST', 'https://api.example.com/orders', 'items.0.sku', 'ABC-001');// What was sent in requests
Http::assertCookieSent('session');
Http::assertCookieNotSent('tracking');
Http::assertCookieSentToUrl('session', 'https://api.example.com/*');
Http::assertCookieNotSentToUrl('admin_token', 'https://public.example.com/*');
// What is stored in the cookie jar
Http::assertCookieExists('session');
Http::assertCookieValue('theme', 'dark');
Http::assertCookieExpired('old_token');
Http::assertCookieNotExpired('session');
Http::assertCookieIsSecure('session');
Http::assertCookieIsHttpOnly('session');
Http::assertCookieIsHostOnly('tracking');
Http::assertCookieHasAttributes('session', [
'secure' => true,
'httpOnly' => true,
'path' => '/',
'sameSite' => 'Strict',
]);Http::assertDownloadMade('https://files.example.com/report.pdf', '/tmp/report.pdf');
Http::assertDownloadMadeToUrl('https://files.example.com/report.pdf');
Http::assertFileDownloaded('/tmp/report.pdf');
Http::assertNoDownloadsMade();
Http::assertDownloadCount(2);
Http::assertDownloadWithMethod('https://files.example.com/report.pdf', 'GET');
Http::assertDownloadWithHeaders('https://files.example.com/report.pdf', [
'Authorization' => 'Bearer my-token',
]);
Http::assertDownloadedFileExists('/tmp/report.pdf');
Http::assertDownloadedFileContains('/tmp/report.pdf', '%PDF-1.4');
Http::assertDownloadedFileContainsString('/tmp/report.pdf', 'Summary');
Http::assertDownloadedFileSize('/tmp/report.pdf', 204800);
Http::assertDownloadedFileSizeBetween('/tmp/report.pdf', minSize: 100_000, maxSize: 500_000);Http::assertUploadMade('https://storage.example.com/files', '/local/path/file.csv');
Http::assertUploadMadeToUrl('https://storage.example.com/files');
Http::assertNoUploadsMade();
Http::assertUploadCount(1);Http::assertStreamMade('https://api.example.com/stream');
Http::assertStreamWithCallback('https://api.example.com/stream');
Http::assertStreamWithMethod('https://api.example.com/stream', 'POST');
Http::assertStreamWithHeaders('https://api.example.com/stream', ['Accept' => 'text/plain']);
Http::assertNoStreamsMade();
Http::assertStreamCount(1);// Connection presence
Http::assertSSEConnectionMade('https://api.example.com/events');
Http::assertNoSSEConnections();
Http::assertSSEConnectionCount('https://api.example.com/events', expectedCount: 3);
Http::assertSSEConnectionAttempts('https://api.example.com/events', expectedAttempts: 3);
Http::assertSSEConnectionAttemptsAtLeast('https://api.example.com/events', minAttempts: 2);
Http::assertSSEConnectionAttemptsAtMost('https://api.example.com/events', maxAttempts: 5);
// Reconnection behaviour
Http::assertSSEReconnectionOccurred('https://api.example.com/events');
Http::assertFirstSSEConnectionHasNoLastEventId('https://api.example.com/events');
Http::assertSSELastEventId('42'); // last request
Http::assertSSELastEventId('42', requestIndex: 2); // specific attempt
Http::assertSSEReconnectionProgression('https://api.example.com/events'); // IDs monotonically increasing
// Headers on SSE connections
Http::assertSSEConnectionAuthenticated('https://api.example.com/events', expectedToken: 'my-token');
Http::assertSSEConnectionHasHeader('https://api.example.com/events', 'X-Tenant-Id', 'acme');
Http::assertSSEConnectionMissingHeader('https://api.example.com/events', 'X-Debug');
Http::assertSSEConnectionRequestedWithProperHeaders('https://api.example.com/events');
// Multiple and ordered connections
Http::assertSSEConnectionsMadeToMultipleUrls([
'https://api.example.com/events/prices',
'https://api.example.com/events/alerts',
]);
Http::assertSSEConnectionsInOrder([
'https://api.example.com/events/auth',
'https://api.example.com/events/stream',
]);$last = Http::getLastRequest();
$first = Http::getRequest(0);
$history = Http::getRequestHistory();
// RecordedRequest API
$last->getMethod(); // 'POST'
$last->getUrl(); // 'https://api.example.com/users'
$last->getHeaders(); // ['content-type' => 'application/json', ...]
$last->getHeader('authorization'); // 'Bearer token' or array for multi-value headers
$last->getHeaderLine('accept'); // 'application/json'
$last->hasHeader('x-debug'); // false
$last->getBody(); // raw body string
$last->getJson(); // parsed array or null
$last->isJson(); // bool
$last->getOptions(); // raw cURL options array
// Filtered views
Http::getRequestsByMethod('POST');
Http::getRequestsTo('https://api.example.com/users');
Http::getDownloadRequests();
Http::getLastDownload();
Http::getFirstDownload();
Http::getDownloadDestination('https://files.example.com/report.pdf');
Http::getUploadRequests();
Http::getLastUpload();
Http::getStreamRequests();
Http::getLastStream();
Http::getFirstStream();
Http::streamHasCallback($request); // bool
Http::getSSEConnectionAttempts('https://api.example.com/events');Http::dumpLastRequest(); // prints method, URL, headers, body
Http::dumpRequestsByMethod('POST');
Http::dumpDownloads();
Http::dumpLastDownload();
Http::dumpStreams();
Http::dumpLastStream();| Method | Description |
|---|---|
Http::startTesting() |
Enable testing mode. Returns the TestingHttpHandler instance. |
Http::stopTesting() |
Disable testing mode and clear all state. |
Http::resetTesting() |
Clear mocks and history without disabling testing mode. |
Http::getTestingHandler() |
Return the active testing handler. Throws if not in testing mode. |
Http::mock(string $method) |
Create a new MockRequestBuilder. |
$handler->reset() |
Clear all mocks, history, cookies, and temp files (DI mode). |
$handler->enablePassthrough() |
Allow unmatched requests to reach the real network. |
$handler->disablePassthrough() |
Restore strict matching — unmatched requests throw. |
$handler->enableNetworkSimulation(array $settings) |
Enable global network simulation. |
$handler->disableNetworkSimulation() |
Disable network simulation. |
$handler->withFastNetwork() |
Preset: sub-100ms, near-zero failure rate. |
$handler->withMobileNetwork() |
Preset: 0.5–3s latency, 8% failure rate. |
$handler->withPoorNetwork() |
Preset: 1–5s latency, 15% failure rate. |
$handler->withUnstableNetwork() |
Preset: high variability, 20% failure rate. |
$handler->withGlobalRandomLatencey(float $min, float $max) |
Add a global random latency range to all requests. |
$handler->withoutGlobalrandomLatency() |
Remove global random latency. |
| Method | Description |
|---|---|
->url(string $pattern) |
URL pattern to match using fnmatch wildcards. |
->expect(callable $callback) |
Custom closure matcher receiving a RecordedRequest. |
->expectHeader(string $name, string $value) |
Require a specific header in the request. |
->expectHeaders(array $headers) |
Require multiple headers. |
->expectBody(string $pattern) |
Require the request body to match a pattern. |
->expectJson(array $data) |
Require the request body to match exact JSON. |
->expectCookies(array $cookies) |
Require specific cookies to be present. |
| Method | Description |
|---|---|
->respondWithStatus(int $status) |
Set the HTTP response status code. |
->status(int $status) |
Alias for respondWithStatus(). |
->respondWith(string $body) |
Set the response body as a plain string. |
->respondJson(array $data) |
Set the response body as JSON. Sets Content-Type: application/json. |
->respondXml(string|\SimpleXMLElement $xml) |
Set the response body as XML. Sets Content-Type: application/xml. |
->respondWithHeader(string $name, string|array $value) |
Add a response header. |
->respondWithHeaders(array $headers) |
Add multiple response headers. |
->respondWithChunks(array $chunks) |
Set a sequence of body chunks to simulate streaming. |
->persistent() |
Make this mock reusable for multiple requests. |
->register() |
Activate the mock. Must be called at the end of every chain. |
| Method | Description |
|---|---|
->latency(float $seconds) |
Fixed delay before responding. |
->slowResponse(float $seconds) |
Alias for latency(). |
->randomLatency(float $min, float $max) |
Random delay chosen once at registration. |
->randomPersistentLatency(float $min, float $max) |
Fresh random delay on every match (implies persistent). |
->dataStreamTransferLatency(float $seconds, float $jitter) |
Per-chunk delay for downloads, streams, and SSE. |
->fail(string $error) |
Make the mock fail with a hard error. |
->timeout(float $seconds) |
Simulate a timeout. |
->timeoutFailure(float $timeoutAfter, ?string $message) |
Retryable timeout failure. |
->retryableFailure(string $error) |
Fail with a retryable error. |
->networkError(string $type) |
Simulate a specific network error type (connection, timeout, resolve, ssl). |
| Method | Description |
|---|---|
->failUntilAttempt(int $successAttempt, string $error) |
Fail N−1 times, succeed on attempt N. |
->timeoutUntilAttempt(int $successAttempt, float $timeoutAfter) |
Timeout N−1 times, succeed on attempt N. |
->statusFailuresUntilAttempt(int $successAttempt, int $failureStatus) |
Return error status N−1 times, succeed on attempt N. |
->failWithSequence(array $failures, mixed $successResponse) |
Custom sequence of failure types then success. |
->mixedFailuresUntilAttempt(int $successAttempt) |
Cycle through timeout, connection, DNS, SSL failures until success. |
->intermittentFailures(array $pattern) |
Explicit boolean pattern of fails and successes. |
->rateLimitedUntilAttempt(int $successAttempt) |
Return 429 with Retry-After until success. |
->slowlyImproveUntilAttempt(int $successAttempt, float $maxDelay) |
Simulate network recovery with decreasing delays. |
| Method | Description |
|---|---|
->downloadFile(string $content, ?string $filename, string $contentType) |
Mock a file download response. |
->downloadLargeFile(int $sizeInKB, ?string $filename) |
Mock a large file download with generated content. |
| Method | Description |
|---|---|
->setCookie(string $name, string $value, ...) |
Add a Set-Cookie header to the response. |
->setCookies(array $cookies) |
Add multiple Set-Cookie headers. |
| Method | Description |
|---|---|
->respondWithSSE(array $events) |
Configure as an SSE response with a list of events. |
->addSSEEvent(?string $data, ?string $event, ?string $id, ?int $retry) |
Add a single SSE event. |
->sseWithKeepalive(array $dataEvents, int $keepaliveCount) |
SSE with keepalive events between data events. |
->sseHeartbeatOnly(int $heartbeatCount) |
SSE that sends only heartbeat (empty data) events. |
->sseWithRetry(array $events, int $retryMs) |
SSE with a custom retry interval field. |
->sseWithRetryDirective(int $retryMs, array $events) |
SSE that sends only a retry directive. |
->sseWithEventIds(array $events) |
SSE with event IDs for reconnection scenarios. |
->sseMultipleTypes(array $eventsByType) |
SSE with multiple named event types. |
->sseWithComments(array $events, array $comments) |
SSE with comment lines interspersed. |
->sseExpectLastEventId(string $id, array $eventsAfterResume) |
Match only requests that carry a specific Last-Event-ID. |
->sseDisconnectAfter(int $count, string $error) |
Send N events then simulate a network drop. |
->sseWithPeriodicEvents(array $events) |
Emit events one by one via a timer (use with dataStreamTransferLatency). |
->sseWithLimitedEvents(int $count, ?callable $generator) |
Emit N generated events then close. |
->sseInfiniteStream(callable $generator, ?int $maxEvents) |
Emit events indefinitely until the client cancels. |
->ssePeriodicThenDisconnect(int $count, string $error, ?callable $generator) |
Emit N events then simulate a network drop. |
| Method | Description |
|---|---|
->sseFailUntilAttempt(int $successAttempt, array $successEvents, string $error) |
Fail N−1 SSE connections, succeed on attempt N. |
->sseTimeoutUntilAttempt(int $successAttempt, array $successEvents, float $timeout) |
Timeout N−1 SSE connections, succeed on attempt N. |
->sseFailWithSequence(array $failures, array $successEvents) |
Custom sequence of SSE failures then success. |
->sseDropAfterEvents(array $events, string $error, bool $retryable) |
Send events then drop the connection. |
->sseReconnectFromEventId(string $id, array $eventsAfterResume) |
Match reconnection request with a specific Last-Event-ID. |
->sseRateLimitedUntilAttempt(int $successAttempt, array $successEvents) |
Return 429 until success. |
->sseNetworkErrorsUntilAttempt(int $successAttempt, array $successEvents) |
Cycle through network errors until success. |
->sseSlowlyImproveUntilAttempt(int $successAttempt, array $successEvents, float $maxDelay) |
Simulate network recovery for SSE connections. |
->sseMixedFailuresUntilAttempt(int $successAttempt) |
Cycle through mixed failure types until success. |
->sseIntermittentFailures(array $pattern) |
Explicit boolean pattern of SSE fails and successes. |
Here's the updated section with the name swapped throughout:
use Hibla\HttpClient\Http;
beforeEach(fn () => Http::startTesting());
afterEach(fn () => Http::stopTesting());
it('creates a user and returns the new resource', function () {
Http::mock('POST')
->url('https://api.example.com/users')
->expectHeader('Authorization', 'Bearer secret')
->expectJson(['name' => 'Reymart Calicdan', 'role' => 'admin'])
->respondWithStatus(201)
->respondJson(['id' => 1, 'name' => 'Reymart Calicdan'])
->register();
$service = new UserService(token: 'secret');
$user = $service->create(name: 'Reymart Calicdan', role: 'admin');
expect($user->id)->toBe(1)
->and($user->name)->toBe('Reymart Calicdan');
Http::assertRequestCount(1);
Http::assertRequestMade('POST', 'https://api.example.com/users');
Http::assertBearerTokenSent('secret');
Http::assertContentType('application/json');
Http::assertRequestJsonContains('POST', 'https://api.example.com/users', ['role' => 'admin']);
});
it('retries on 503 and eventually succeeds', function () {
Http::mock('POST')
->url('https://api.example.com/orders')
->statusFailuresUntilAttempt(successAttempt: 3, failureStatus: 503)
->register();
$result = (new OrderService())->placeOrder(['item' => 'book']);
expect($result)->toBeTrue();
Http::assertRequestCount(3);
});
it('downloads a report and writes it to disk', function () {
Http::mock('GET')
->url('https://files.example.com/report.pdf')
->downloadFile(content: '%PDF-1.4 fake content', filename: 'report.pdf', contentType: 'application/pdf')
->register();
$destination = Http::getTempPath('report.pdf');
(new ReportService())->download(destination: $destination);
Http::assertDownloadMade('https://files.example.com/report.pdf', $destination);
Http::assertDownloadedFileContainsString($destination, '%PDF-1.4');
});use Hibla\HttpClient\Http;
beforeEach(fn () => Http::startTesting());
afterEach(fn () => Http::stopTesting());
it('receives SSE events and processes them', function () {
Http::mock('GET')
->url('https://api.example.com/events')
->respondWithSSE([
['event' => 'connected', 'data' => '{"status":"ready"}', 'id' => '1'],
['event' => 'update', 'data' => '{"value":42}', 'id' => '2'],
])
->register();
$received = [];
(new EventService())->listen(
url: 'https://api.example.com/events',
onEvent: fn ($event) => $received[] = $event
);
expect($received)->toHaveCount(2)
->and($received[0]->event)->toBe('connected')
->and($received[1]->event)->toBe('update');
Http::assertSSEConnectionMade('https://api.example.com/events');
Http::assertSSEConnectionRequestedWithProperHeaders('https://api.example.com/events');
});
it('reconnects after a dropped SSE connection using Last-Event-ID', function () {
Http::mock('GET')
->url('https://api.example.com/events')
->sseDropAfterEvents(
eventsBeforeDrop: [
['id' => '1', 'data' => '{"msg":"first"}'],
['id' => '2', 'data' => '{"msg":"second"}'],
],
dropError: 'Connection lost'
)
->register();
Http::mock('GET')
->url('https://api.example.com/events')
->sseExpectLastEventId('2', eventsAfterResume: [
['id' => '3', 'data' => '{"msg":"resumed"}'],
])
->register();
(new EventService())->listenWithReconnect('https://api.example.com/events');
Http::assertSSEConnectionAttempts('https://api.example.com/events', expectedAttempts: 2);
Http::assertSSEReconnectionOccurred('https://api.example.com/events');
Http::assertFirstSSEConnectionHasNoLastEventId('https://api.example.com/events');
Http::assertSSELastEventId('2', requestIndex: 1);
Http::assertSSEReconnectionProgression('https://api.example.com/events');
});
it('streams a periodic price feed', function () {
Http::mock('GET')
->url('https://api.example.com/prices')
->sseInfiniteStream(
eventGenerator: fn (int $i) => [
'event' => 'price',
'data' => json_encode(['tick' => $i, 'value' => 100 + $i]),
'id' => (string) $i,
],
maxEvents: 5
)
->dataStreamTransferLatency(0.01)
->register();
$ticks = [];
(new PriceFeedService())->subscribe(
url: 'https://api.example.com/prices',
onPrice: fn ($event) => $ticks[] = json_decode($event->data, true)
);
expect($ticks)->toHaveCount(5)
->and($ticks[0]['tick'])->toBe(0)
->and($ticks[4]['tick'])->toBe(4);
Http::assertSSEConnectionMade('https://api.example.com/prices');
});use Hibla\HttpClient\HttpClient;
use Hibla\HttpClient\Testing\TestingHttpHandler;
beforeEach(function () {
$this->handler = new TestingHttpHandler();
$this->client = new HttpClient()->withHandler($this->handler);
$this->service = new UserService($this->client);
});
afterEach(fn () => $this->handler->reset());
it('fetches a list of users', function () {
$this->handler->mock('GET')
->url('https://api.example.com/users')
->respondJson(['users' => [['id' => 1, 'name' => 'Reymart Calicdan']]])
->register();
$users = $this->service->list();
expect($users)->toHaveCount(1);
$this->handler->assertRequestMade('GET', 'https://api.example.com/users');
$this->handler->assertHeaderSent('Accept', 'application/json');
});
it('sends cookies after login', function () {
$this->handler->mock('POST')
->url('https://api.example.com/login')
->respondWithStatus(200)
->setCookie(name: 'session', value: 'tok-abc', secure: true, httpOnly: true)
->register();
$this->handler->mock('GET')
->url('https://api.example.com/profile')
->expectCookies(['session' => 'tok-abc'])
->respondJson(['name' => 'Reymart Calicdan'])
->register();
$this->service->login('[email protected]', 'password');
$this->service->profile();
$this->handler->assertCookieIsSecure('session');
$this->handler->assertCookieIsHttpOnly('session');
$this->handler->assertCookieSentToUrl('session', 'https://api.example.com/profile');
});use Hibla\HttpClient\Http;
beforeEach(fn () => Http::startTesting()->enablePassthrough());
afterEach(fn () => Http::stopTesting());
it('mocks the payment gateway but hits staging for order data', function () {
Http::mock('POST')
->url('https://payments.example.com/charge')
->respondWithStatus(200)
->respondJson(['charged' => true, 'id' => 'ch_001'])
->register();
$result = (new CheckoutService())->checkout(orderId: 'ord-123');
expect($result->charged)->toBeTrue();
Http::assertRequestMade('POST', 'https://payments.example.com/charge');
Http::assertRequestJsonContains('POST', 'https://payments.example.com/charge', ['order_id' => 'ord-123']);
// Real request to staging — still recorded and assertable
Http::assertRequestMade('GET', 'https://api.staging.example.com/orders/ord-123');
Http::assertBearerTokenSent('staging-token');
});use Hibla\HttpClient\Http;
use PHPUnit\Framework\TestCase;
class OrderServiceTest extends TestCase
{
protected function setUp(): void { Http::startTesting(); }
protected function tearDown(): void { Http::stopTesting(); }
public function test_places_order_and_sends_confirmation(): void
{
Http::mock('POST')
->url('https://api.example.com/orders')
->expectHeader('Authorization', 'Bearer secret')
->expectJson(['item' => 'book', 'qty' => 2])
->respondWithStatus(201)
->respondJson(['id' => 'ord-001', 'status' => 'confirmed'])
->register();
Http::mock('POST')
->url('https://mailer.example.com/send')
->respondWithStatus(200)
->register();
$service = new OrderService(token: 'secret');
$service->placeOrder(item: 'book', qty: 2, email: '[email protected]');
Http::assertRequestCount(2);
Http::assertRequestMade('POST', 'https://api.example.com/orders');
Http::assertRequestJsonContains('POST', 'https://api.example.com/orders', ['item' => 'book']);
Http::assertBearerTokenSent('secret', requestIndex: 0);
Http::assertRequestMade('POST', 'https://mailer.example.com/send');
}
}When PHPUnit is available, all assert* methods register themselves with PHPUnit's assertion counter automatically, so they appear in test output and contribute to the assertion count. No extra configuration is required.
When PHPUnit is not present (for example, in standalone PHP projects without PHPUnit), failed assertions throw MockAssertionError instead.
git clone https://github.com/hiblaphp/http-client-testing.git
cd http-client-testing
composer install./vendor/bin/pest./vendor/bin/phpstan analyse- API Design: Inspired by Laravel Http::fake() api.
MIT License. See LICENSE for more information.