Comment tester une API externe en ayant 0 Mocks ?


Imen EZZINE

Développeuse PHP Symfony
Café Tech avec Imen


@imenezzine
Communauté de Symfony en Tunisie

Imen Ezzine
Contexte






Comment tester une application consommant une API externe ?


-
Consommer réellement l'API
- Sandbox ou environement dédié
- Virtualisation de l'API en local
- Avec mock (PHPUnit, ...)
Les possibilités
| 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 |

- Moins de code possible ?
- Moins de configuration ou plutôt automatisation ?
- Moins de maintenance ?
- Décorrélation complète ?
Qu'est ce qu'on cherche ?

"Don’t Mock What You Don’t Own"
the London School of TDD – the one that loooves mocks.



PHP-VCR

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 php-vcr ne suffisait plus
Le Declic


Passage à HttpClient
De quoi a-t-on besoin pour mocker une API ?
La requête HTTP
- méthode
- URL
- headers
- payload
La réponse HTTP
- status code
- headers
- body
+ informations techniques utiles
- timings
- ordre des appels
- dépendances
HAR : Le standard méconnu
HTTP Archive : Un format JSON universel pour loguer les interactions web.
Utilisé par Chrome, Firefox et Edge dans l'onglet Network.
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.
Le Concept : Record & Replay
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.

Test Unitaire ou Fonctionnel ?
Tests Unitaires
Le Mock reste roi. On isole une classe, on simule des erreurs 500 ou des timeouts pour tester la logique interne.
Tests Fonctionnels
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.
L'implémentation native
L'attribut #[UseRecord]
Une intégration élégante via le PHPUnit Bridge.
#[UseRecord]
public function testSync(): void
{
// C'est tout.
// Le HAR est géré automatiquement.
}
Configuration simple
Activez l'extension dans votre fichier phpunit.xml :
<extensions>
<bootstrap class="Symfony\Bridge\PhpUnit\RecorderExtension">
<parameter name="defaultDirectory" value="./tests/"/>
</bootstrap>
</extensions>

Sous le capot du RecorderHttpClient ⚙️



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
En pratique?

Exemple d'utilisation
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}"
}
}
}
]
}
}
Comparatif : Avant vs Après

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
Remerciements

AfupDayParis2026
By imenezzine
AfupDayParis2026
- 40