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

Made with Slides.com