Imen Ezzine Mathieu Santostefano Nicolas Grekas
Contributed by Imen Ezzine , Mathieu Santostefano and Nicolas Grekas in #63781

When testing code that calls external APIs, you usually choose between real HTTP calls (slow and depend on third-party services), and hand-written mocks (tedious and often differ from what the API actually returns). Libraries like php-vcr popularized a third option: record the real exchanges once and replay them in your tests. Symfony 8.2 adds this feature natively to the HttpClient component.

Enabling the Recorder

The new RecorderHttpClient decorates the HTTP client transport, so it covers both the default client and your scoped clients. It's driven by the SymfonyExtension of the PHPUnit Bridge, which the Symfony Flex recipe registers in phpunit.dist.xml. You only have to enable the recorder in the test environment:

1
2
3
4
# config/packages/http_client.yaml
when@test:
    http_client:
        recorder: true

Recording and Replaying Your Tests

Add the #[UseRecord] attribute to the tests that call external APIs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// tests/Service/ExchangeRatesTest.php
namespace App\Tests\Service;

use App\Service\ExchangeRates;
use Symfony\Bridge\PhpUnit\Attribute\UseRecord;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class ExchangeRatesTest extends KernelTestCase
{
    #[UseRecord]
    public function testGetRate(): void
    {
        $exchangeRates = static::getContainer()->get(ExchangeRates::class);

        $this->assertSame(1.08, $exchangeRates->getRate('EUR', 'USD'));
    }
}

Run the test once with the SYMFONY_HTTP_RECORDER environment variable to make the real requests and record them (the missing value is explained later):

1
$ SYMFONY_HTTP_RECORDER=missing vendor/bin/phpunit --filter testGetRate

This creates tests/Service/ExchangeRatesTest/testGetRate.har using the standard HAR format, which is human-readable, easy to review in pull requests and supported by browser dev tools. Commit this file; from now on, all test runs (including CI) replay the responses from it. If a request isn't found in the file, Symfony throws a HarEntryNotFoundException instead of reaching the network.

Controlling What Gets Recorded

The attribute only selects the file. Whether a test can reach the network is decided when running the tests, so you can't commit a test that makes real requests by mistake. The SYMFONY_HTTP_RECORDER environment variable accepts these values:

  • replay (the default): replays the responses and fails when a request is not in the file;
  • missing: replays the responses, and makes and records only the requests not in the file;
  • record: always makes the real requests and rewrites the files of the tests that run (useful to refresh them after an API change);
  • passthrough: makes the real requests without reading or writing the files (useful to check your tests against the live services).

Tests without #[UseRecord] aren't affected: their requests are sent as usual. Requests are matched on their method, URL and body, and repeated ones are replayed in the order they were recorded, so sequences like polling an API until a job finishes also work.

Keeping Secrets Out of Recordings

HAR files are committed to your repository, so they must not contain credentials. By default, the recorder replaces secrets in requests and responses with [REDACTED] before writing anything to disk. This includes headers such as Authorization, Cookie and X-Api-Key, and query string, form and JSON fields such as password, api_key, client_secret and access_token.

Requests are also compared with their secrets masked, so they still match when replayed with real credentials. Use the redact option to mask more names and redact_except to remove names from the built-in lists:

1
2
3
4
5
6
# config/packages/http_client.yaml
when@test:
    http_client:
        recorder:
            redact: ['X-Custom-Secret', 'nonce']
            redact_except: ['code']

Masking a field that changes on every request, like nonce here, also excludes it from request matching. For full control, set the redactor option to the ID of a service that implements RedactorInterface.

Note

Replayed responses contain the masked values, so if your code validates something like the signature of an id_token, you'll need a custom redactor.

Published in #Living on the edge