Testing Outbound Calls
Replace the HTTP client with MockHttpClient, assert exactly what your code sent, and keep tests off the real network.
new MockHttpClient(ResponseInterface|callable|iterable|null $responseFactory = null, ?string $baseUri = 'https://example.com')A test that calls a real API is slow, fails whenever the other side is down, and can change data that is not yours. MockHttpClient removes the network entirely. It implements HttpClientInterface, so a service that takes the interface accepts it with no change to the service.
Giving it responses
Its first argument is one response, a list of responses handed out in the order requests are made, a callable that builds a response from $method, $url and $options, or a list of such callables. The second argument is the base URI that relative URLs resolve against, and it defaults to https://example.com. new JsonMockResponse(['id' => 7]) is the shorthand for new MockResponse(json_encode(['id' => 7]), ['response_headers' => ['content-type' => 'application/json']]). Any status other than 200 goes in the info array as ['http_code' => 404]. MockResponse::fromFile() and JsonMockResponse::fromFile(), introduced in Symfony 7.1, read a saved body from disk.
Asserting what was sent
Keep a reference to the MockResponse you passed in. After the call it answers getRequestMethod(), getRequestUrl() with the resolved absolute URL, and getRequestOptions(), which holds the normalised request: ['body'] as a string and ['headers'] as a list of Name: value lines. The client itself answers getRequestsCount().
What trips people up
Responses are consumed in order. If the code under test makes two requests and you supplied one response, the second request throws a transport exception saying the number of requests exceeds the number of responses.
Example
<?php
// tests/Service/ArticlePublisherTest.php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\ArticlePublisher;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
final class ArticlePublisherTest extends TestCase
{
public function testItSendsTheTitleAndReturnsTheNewId(): void
{
// Shorthand: encodes the array and sets content-type on the response.
$mockResponse = new JsonMockResponse(['id' => 7], ['http_code' => 201]);
// Longhand for the same response:
// $mockResponse = new MockResponse(json_encode(['id' => 7]), [
// 'http_code' => 201,
// 'response_headers' => ['content-type' => 'application/json'],
// ]);
// Second argument: the base URI relative URLs resolve against.
$client = new MockHttpClient($mockResponse, 'https://api.example.com/v1/');
$publisher = new ArticlePublisher($client);
self::assertSame(7, $publisher->publish('Testing outbound calls'));
// Now assert what left the application. ArticlePublisher passes an
// absolute URL, so the base URI above is not applied to it.
self::assertSame('POST', $mockResponse->getRequestMethod());
self::assertSame('https://api.example.com/articles', $mockResponse->getRequestUrl());
self::assertSame('{"title":"Testing outbound calls"}', $mockResponse->getRequestOptions()['body']);
self::assertContains('Content-Type: application/json', $mockResponse->getRequestOptions()['headers']);
self::assertSame(1, $client->getRequestsCount());
}
}When to use it
- Prove that a service sends the right method, URL and JSON body to a partner API.
- Test what your code does with a 500 or a rate-limit response without waiting for one to happen.
- Keep the test suite green and fast on a machine with no network access or no API credentials.
- Replay a saved response body from a fixture file so a parser test has realistic data.
More examples
A callable that inspects the request before answering
A callable is the right choice when the response depends on the request, or when you want the assertions to run at the moment the call is made. The arguments are the method, the fully resolved URL and the normalised options, so this is where you check a query string, a header or a body without needing the MockResponse afterwards. Returning different responses per URL from one callable lets a single mock stand in for a whole API.
<?php
// tests/Service/ArticleFeedTest.php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\ArticleFeed;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface;
final class ArticleFeedTest extends TestCase
{
public function testItAsksForOnePageOfTwo(): void
{
$callback = function (string $method, string $url, array $options): ResponseInterface {
self::assertSame('GET', $method);
// The "query" option has already been merged into the URL,
// in the order the array declared it.
self::assertSame(
'https://api.example.com/articles?page=1&per_page=2',
$url
);
self::assertContains('Accept: application/json', $options['headers']);
return new JsonMockResponse([
'data' => [['title' => 'First'], ['title' => 'Second']],
]);
};
$feed = new ArticleFeed(new MockHttpClient($callback));
self::assertSame(['First', 'Second'], $feed->titles(2));
}
}Testing the failure path
These are the cases a real network will not give you on demand. The http_code info key produces any status you want, and a generator body that yields an empty string is turned into an idle timeout, which is how you test the timeout branch of a service without waiting for one. Writing the happy path last makes it obvious that the same service is being exercised three ways.
<?php
// tests/Service/RateProviderTest.php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\RateProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Component\HttpClient\Response\MockResponse;
final class RateProviderTest extends TestCase
{
public function testItReturnsNullWhenTheApiFails(): void
{
$client = new MockHttpClient(
new MockResponse('upstream exploded', ['http_code' => 500]),
'https://api.example.com/'
);
self::assertNull((new RateProvider($client))->eurToUsd());
}
public function testItReturnsNullOnAnIdleTimeout(): void
{
// An empty string yielded as a body chunk simulates an idle timeout.
$body = static function (): \Generator {
yield '';
};
$client = new MockHttpClient(new MockResponse($body()), 'https://api.example.com/');
self::assertNull((new RateProvider($client))->eurToUsd());
}
public function testItReadsTheRate(): void
{
$client = new MockHttpClient(
new JsonMockResponse(['rate' => 1.0825]),
'https://api.example.com/'
);
self::assertSame(1.0825, (new RateProvider($client))->eurToUsd());
}
}Mocking every outbound call in the test environment
This replaces the response factory of the real http_client service in the test environment only, so any code path that makes an outbound call gets a fixture instead of a socket. The referenced class is an invokable service with the signature __invoke(string $method, string $url, array $options = []): ResponseInterface, which returns a MockResponse built from a fixture file or from the URL it was given. Reach for it as a safety net for tests that exercise a whole request, where you cannot inject the mock yourself; for a single service, constructing MockHttpClient in the test is clearer.
# config/services_test.yaml
services:
App\Tests\MockClientCallback: ~
# config/packages/test/framework.yaml
framework:
http_client:
mock_response_factory: App\Tests\MockClientCallback
Discussion