Développeuse PHP Symfony
Café Tech avec Imen
@imenezzine
Communauté de Symfony en Tunisie
Imen Ezzine
Comment tester une application consommant une API externe ?
Consommer réellement l'API
| Méthode de Test | Pour | Contre |
|---|---|---|
| - Utilisation de l'API Réelle | - Réalisme des tests - Fiabilité des résultats |
- Dépendance externe - Coût |
| - Utilisation d'une Sandbox | - Isolation des tests - Fiabilité des résultats |
- Limitations de fonctionnalités - Dépendance externe |
| - Utilisation de Mock | - Contrôle total - Performance |
-Écart par rapport à la réalité -exp(Maintenance) |
| - Virtualisation de l'API en Local | - Isolation des tests - Réalisme des tests |
- Configuration complexe - Dépendance des ressources locales |
Le nœud du problème
RuntimeException: Unexpected error, could not find curl_getinfo in response or errors
Pourquoi ça bloque ? Symfony HttpClient exige des informations de sécurité très précises (certificats, métadonnées). PHP VCR intercepte bien la requête, mais ne lui renvoie pas ces données, ce qui fait planter le test.
Pourquoi ne pas l'utiliser comme base pour nos tests ?
{
"startedDateTime": "2026-05-12T09:12:34.567Z",
"time": 312,
"request": {
},
"response": {
},
"timings": {
}
}"request": {
"method": "GET",
"url": "https://api.example.com/v1/products",
"httpVersion": "HTTP/2",
"headers": [
{
"name": "accept",
"value": "application/json"
},
{
"name": "authorization",
"value": "Bearer eyJ..."
}
],
"queryString": [
{
"name": "page",
"value": "1"
}
]
} "response": {
"status": 200,
"statusText": "OK",
"headers": [
{
"name": "content-type",
"value": "application/json"
}
],
"content": {
"size": 1240,
"mimeType": "application/json",
"text": "[{\"id\":1,\"name\":\"Keyboard\"}]"
}
},
"timings": {
"blocked": 0,
"dns": 5,
"connect": 12,
"ssl": 8,
"send": 1,
"wait": 270,
"receive": 24
}Run #1 : On fait le vrai appel API. On capture la réponse et on la sauve en .har.
Zéro latence. Zéro flakiness. 100% de fiabilité.
Run #2 : On intercepte la requête. On cherche dans le HAR.
On rejoue instantanément.
Le Mock reste roi. On isole une classe, on simule des erreurs 500 ou des timeouts pour tester la logique interne.
C'est ici que le Recorder brille. On teste le flux Controller -> API avec des données réelles mais "figées" dans le temps.
Une intégration élégante via le PHPUnit Bridge.
#[UseRecord]
public function testSync(): void
{
// C'est tout.
// Le HAR est géré automatiquement.
}
Activez l'extension dans votre fichier phpunit.xml :
<extensions>
<bootstrap class="Symfony\Bridge\PhpUnit\RecorderExtension">
<parameter name="defaultDirectory" value="./tests/"/>
</bootstrap>
</extensions>
Test PHPUnit
! #[UseRecord] RecorderHttpClient
(Decorator / Wrapper)
PASSTHROUGH
HttpClient réel
(Symfony client)
DefaultMatcher
Store HAR (.har)
Class/method.har
REPLAY_AND_RECORD_IF_MISSING
MockHttpClient
Réponse mockée
Oui
Appel HTTP réel
Non
Réponse réelle
#[UseRecord]Appel réseau réel
class GetRandomController
{
#[Route('/random')]
public function __invoke(HttpClientInterface $client): Response
{
$response = $client->request('GET', 'https://meowfacts.herokuapp.com/');
// ...
}
}use Symfony\Bridge\PhpUnit\HttpClientRecorder\Attribute\UseRecord;
use Symfony\Component\HttpClient\RecorderMode;
#[UseRecord]
public function testRandom(): void
{
$client = static::createClient();
$client->request('GET', '/random');
$response = $client->getResponse();
$this->assertSame(200, $response->getStatusCode());
$content = $response->getContent();
$this->assertJson($content);
$json = json_decode($content, true);
$this->assertSame('random quote', $json['data']['fact'] ?? null);
}{
"log": {
"version": "1.2",
"creator": {
"name": "HttpRecorder"
},
"entries": [
{
"startedDateTime": "2026-03-26T16:56:06.969Z",
"request": {
"method": "GET",
"url": "https:\/\/catfact.ninja\/fact",
"postData": null
},
"response": {
"status": 200,
"headers": {
"date": [
"Thu, 26 Mar 2026 16:56:07 GMT"
],
"content-type": [
"application\/json"
],
"server": [
"cloudflare"
],
"vary": [
"Accept-Encoding"
],
"cache-control": [
"no-cache, private"
],
},
"content": {
"text": "{\"fact\":\"While it is commonly thought that the ancient Egyptians were the first to domesticate cats,
the oldest known pet cat was recently found in a 9,500-year-old grave on the Mediterranean island of Cyprus.
This grave predates early Egyptian art depicting cats by 4,000 years or more.\",\"length\":278}"
}
}
}
]
}
}use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use App\Service\ProductSyncService;
class ProductTest extends KernelTestCase
{
public function testProductSyncWithMocks(): void
{
self::bootKernel();
//1. On doit définir manuellement toutes les réponses dans l'ordre exact
$responses = [
new MockResponse(json_encode([
'access_token' => 'fake_token',
'expires_in' => 3600
]), ['http_code' => 200]),
new MockResponse(json_encode([
'identifier' => 'iphone_15',
'enabled' => true,
'family' => 'smartphones',
'values' => [
'description' => [['data' => 'Super téléphone', 'locale' => 'fr_FR']],
]
]), ['http_code' => 200]),
];
// 2. On instancie le client de mock
$mockHttpClient = new MockHttpClient($responses);
// 3. On injecte manuellement le mock dans le service
self::getContainer()->set('http_client', $mockHttpClient);
$productService = self::getContainer()->get(ProductSyncService::class);
$product = $productService->syncProduct('iphone_15');
$this->assertEquals('iphone_15', $product->getIdentifier());
}
}use Symfony\Bridge\PhpUnit\Attribute\UseRecord;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use App\Service\ProductSyncService;
class ProductTest extends KernelTestCase
{
#[UseRecord]
public function testProductSyncWithRecorder(): void
{
self::bootKernel();
$productService = self::getContainer()->get(ProductSyncService::class);
$product = $productService->syncProduct('iphone_15');
$this->assertEquals('iphone_15', $product->getIdentifier());
}
}| Aspect | MockHttpClient | RecorderHttpClient |
|---|---|---|
| Source de vérité | Le développeur | L'API réelle (réalité) |
| Volume de code | Élevé (Setup des réponses) | Nul (Attribut unique, génération automatique) |
| Mise à jour | Manuelle | Automatisée |
| Debug | Moyen (Fichiers locaux ou JSON masqué dans le code) | Facile (Ouvrir le .har dans l'IDE, plus complet) |
Une PR sur Symfony 8.2 officielle est soumis sur le dépôt Symfony pour intégrer le mécanisme de recording directement dans le composant HttpClient.
Ce que propose la PR
Ajouter un RecordingHttpClient natif dans symfony/http-client, sans dépendance externe.
Vers une intégration native dans Symfony
Si mergée, cette fonctionnalité sera disponible nativement pour toute la communauté Symfony.
Hubert Lenoir
Mathieu Santostefano
Adrien Roches