Substitution simple d'API avec le client HTTP Symfony
Publié le 12/02/2022 • Actualisé le 12/02/2022
Dans cet article, nous voyons comment substituer des API avec le client HTTP Symfony, déclarer un client spécialisé et comment le tester avec ou sans mock. C'est parti ! 😎
» Publié dans "Une semaine Symfonique 791" (du 21 au 27 férvier 2022).
Prérequis
Je présumerai que vous avez au moins les connaissances fondamentales de Symfony et que vous savez tester une application Symfony avec PHPUnit (jetez un coup d'œil à mon dernier article sur le sujet)
Configuration
- PHP 8.3
- Symfony 6.4.10
- PHPUnit 9.5.26
Introduction
Dans mon article précédent, nous avons vu une catégorie spéciale de tests "externes", faisant réellement des appels HTTP sur le réseau. Mais quid, si nous ne voulons pas faire de vrais appels réseaux et souhaitons pouvoir lancer les test hors-ligne ? Nous pouvons utiliser ce que nous appelons des mocks. Cela signifie que nous allons simuler une réponse réelle avec de fausses données qui respectent la structure de la source de données originale.
But
Le but est donc d'utiliser un mock au lieu de faire un véritable appel HTTP en environnement de test. Nous allons mettre en place une solution simple sans avoir à ajouter de dépendances externes.
Un test "externe"
Tout d'abord, jetons un coup d'œil à un test externe :
<?php
declare(strict_types=1);
namespace App\Tests\External\Controller;
use App\Controller\WhatIsMyIpAction;
use App\Tests\WebTestCase;
final class WhatsMyIpActionTest extends WebTestCase
{
/**
* @see WhatIsMyIpAction
*/
public function testWhatIsMyIpActionEn(): void
{
$client = self::createClient();
$client->enableProfiler();
$client->request('GET', '/en/tools/what-is-my-ip');
self::assertResponseIsSuccessful();
$content = (string) $client->getResponse()->getContent();
self::assertStringContainsString('Your IP is:', $content);
self::assertStringContainsString('300.300.300.300', $content);
}
}
Ce test assez explicite ; c'est un "test de fumée" (smoke test 🇬🇧), on teste si la page fonctionne, ne retourne pas d'erreur 500 et qu'elle affiche bien l'IP de l'utilisateur. L'action en charge est classique, elle passe juste les données du service récupérant l'IP de l'utilisateur au template Twig. Voyons le service en charge de récupérer les données de l'API externe :
<?php
declare(strict_types=1);
namespace App\Utility;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @see https://app.abstractapi.com/api/ip-geolocation/documentation
*/
final readonly class AbstractApi
{
public function __construct(
private HttpClientInterface $abstractApiClient,
private string $abstractApiKey
) {
}
// Snippet L15+12 used in templates/blog/posts/_64.html.twig
/**
* @return array<string, mixed>
*/
public function getData(string $ip): array
{
$query = [
'api_key' => $this->abstractApiKey, // your secret API key
'ip_address' => $ip, // if IP is not set, it uses the one of the current request
];
return $this->abstractApiClient->request('GET', '/v1', ['query' => $query])->toArray();
}
}
Quelques notes ; on injecte un service et un paramètre nommé. Comme on peut le voir, le service implémente l'interface HttpClientInterface
. Le composant HTTP de Symfony le génère. Il n'y a d'une fonction, elle prépare la requête à effectuer en construisant un tableau comprenant l'IP à utiliser et la clé secrète permettant de s'identifier (passer la clé en tant que paramètre GET n'est pas vraiment une bonne pratique, dans ce cas, une clé dans l'entête, par exemple, serait plus adaptée). Voyons comme le client HTTP est configuré.
Configuration du client HTTP
Pour pouvoir injecter le service du client HTTP, il doit être déclaré ; ce doît être fait dans le fichier config/packages/framework.yaml
:
framework:
# https://symfony.com/doc/current/components/http_client.html#symfony-framework-integration
http_client:
default_options:
max_redirects: 5
scoped_clients:
# Specialized client to consume the AbstractAPI
abstract.api.client:
timeout: 10
base_uri: 'https://ipgeolocation.abstractapi.com'
headers:
'Accept': 'application/json'
'Content-Type': 'application/json'
'User-Agent': 'strangebuzz.com-v%app_version%'
On utilise un client dédié (scoped client 🇬🇧) ; cela signifie qu'il est assigné à une URL de base donnée. Quand on l'utilise, on a plus qu'à gérer des URL relatives et nous pouvons donc oublier le protocole et domaine de base. Lorsqu'on déclare cette configuration, le composant Symfony HTTP fait plusieurs choses intéressantes. Il crée un service pour chaque client déclaré, ceux-ci sont prêts à être utilisés. Mais que se passe-t-il si l'on a plusieurs clients ? Et bien, cerise-sur-le-gâteau, Symfony crée des paramètres nommés pour chacun d'entre eux. Ils sont prêts à être injectés dans nos services : private HttpClientInterface $abstractApiClient
. Pas besoin de configuration supplémentaire. Comme vous vous pouvez le voir, on assigne quelques entêtes pour indiquer que nous allons consommer l'API avec du JSON grâce aux clés Accept
et Content-Type
. J'utilise aussi un referrer particulier, mais ce n'est pas obligatoire.
Le plus important ici, c'est que l'on injecte une interface HttpClientInterface
dans notre service. Cela veut dire que nous respectons le principe de substitution de Liskov, et nous pouvons donc remplacer ce client par tout objet implémentant ce contrat. C'est ce que nous allons faire dans l'environnement de test.
Création du mock HttpInterface
On n'a pas besoin de réinventer la roue : le composant HTTP dispose déjà d'une telle classe : MockHttpClient
. Jetons un œil à sa déclaration (merci à Nicolas Grekas et aux autres contributeurs pour ce composant, 🙂).
/**
* A test-friendly HttpClient that doesn't make actual HTTP requests.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class MockHttpClient implements HttpClientInterface, ResetInterface
{
use HttpClientTrait;
Parfait ! Comme prévu, cette classe implémente l'interface HttpClientInterface
. On peut l'utiliser pour créer notre nouveau mock. Ajoutons le fichier src/Tests/Mock/AbstractApiMock.php
Cette classe étend donc MockHttpClient
:
<?php
declare(strict_types=1);
namespace App\Tests\Mock;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpFoundation\Response;
final class AbstractApiMock extends MockHttpClient
{
private string $baseUri = 'https://api.example.com';
public function __construct()
{
$callback = \Closure::fromCallable([$this, 'handleRequests']);
parent::__construct($callback, $this->baseUri);
}
private function handleRequests(string $method, string $url): MockResponse
{
if ($method === 'GET' && str_starts_with($url, $this->baseUri.'/v1')) {
return $this->getV1Mock();
}
throw new \UnexpectedValueException("Mock not implemented: $method/$url");
}
/**
* "/v1" endpoint.
*/
private function getV1Mock(): MockResponse
{
$mock = [
'ip_address' => '300.300.300.300',
'city' => 'Paris',
'flag' => [
'emoji' => '🇫🇷',
],
];
return new MockResponse(
json_encode($mock, JSON_THROW_ON_ERROR),
['http_code' => Response::HTTP_OK]
);
}
}
Quelques explications : on utilise une URL de base spécifique, la valeur par défaut est https://example.com
, mais on peut en fait mettre ce que l'on veut ici. La fonction handleRequests()
fait la plus grosse partie du travail : elle est responsable de l'identification de la requête demandée et retourne le mock correspondant. Ici, on ne gère qu'un endpoint /v1
avec la méthode GET. On crée un tableau qui respecte le retour de l'API originale, puis on l'encode en JSON. C'est en fait un sous-ensemble de la vraie réponse, car je n'utilise que ces trois champs dans le template Twig. Ça permet d'avoir un mock concis, mais c'est aussi bien d'avoir un exemple de la réponse complète afin d'avoir une référence à portée de main, sans avoir à consulter la documentation de l'API. Voilà, notre nouveau mock est prêt à être utilisé ; voyons comment l'activer dans l'environnement de test.
Utilisation du mock dans l'environnement de test
Grâce au fantastique système de configuration et d'environnements de Symfony, remplacer un service par un autre dans un environnement donné se révèle assez simple. Ouvrons ou créons un fichier config/services_test.yaml
avec le contenu suivant :
services:
_defaults:
autowire: true
autoconfigure: true
App\Tests\Mock\AbstractApiMock:
decorates: 'abstract.api.client'
decoration_inner_name: 'App\Tests\Mock\AbstractApiMock.abstract.api.client'
arguments: ['@.inner']
Oui, c'est aussi facile que cela. On décore le service abstract.api.client
avec le mock que nous venons de créer. Dans le chapitre suivant, nous verrons les raisons derrières les valeurs assignées aux paramètres optionnels decoration_inner_name
et arguments
. Lançons le test :
./vendor/bin/phpunit --filter=testWhatIsMyIpActionEn
PHPUnit 9.5.13 by Sebastian Bergmann and contributors. Testing . 1 / 1 (100%) Time: 00:00.317, Memory: 52.50 MB OK (1 test, 2 assertions)
Notez le temps d'exécution. Maintenant, commentons la déclaration du mock dans le fichier config/services_test.yaml
et relançons le test :
PHPUnit 9.5.13 by Sebastian Bergmann and contributors. Testing . 1 / 1 (100%) Time: 00:01.105, Memory: 54.50 MB OK (1 test, 2 assertions)
Le temps d'exécution dépasse cette fois une seconde. C'est parce qu'un vrai appel HTTP est de nouveau fait au lieu d'utiliser le mock. Grâce au profileur, améliorons nos tests en contrôlant les appels HTTP effectués.
Tout d'abord, on active le profileur. Puis, on peut récupérer les informations grâce au collecteur dédié au client HTTP :
final class WhatIsMyIpActionTest extends WebTestCase
{
/**
* @see WhatIsMyIpAction
*/
public function testWhatIsMyIpActionEn(): void
{
$client = self::createClient();
$client->enableProfiler();
$client->request('GET', '/en/tools/what-is-my-ip');
self::assertResponseIsSuccessful();
$content = (string) $client->getResponse()->getContent();
self::assertStringContainsString('Your IP is:', $content);
self::assertStringContainsString('300.300.300.300', $content);
/** @var HttpProfile $profile */
$profile = $client->getProfile();
self::assertInstanceOf(HttpProfile::class, $profile);
/** @var HttpClientDataCollector $httpClientCollector */
$httpClientCollector = $profile->getCollector('http_client');
// self::assertSame(1, $httpClientCollector->getRequestCount()); // @checkme 0 after dep update
self::assertSame(0, $httpClientCollector->getErrorCount());
}
La première partie du test est identique à l'ancien à part que l'on appelle $client->enableProfiler();
pour activer le profileur. Si nous ne le faisons pas $client->getProfile();
retournait null
et l'assertion suivante assertInstanceOf
échouerait. Après, on teste qu'une requête à bien été faite (même si elle s'avère factice), et qu'aucune erreur n'est rencontrée.
Mais une minute ? Comment être sûr que le mock est bien utilisé ? Le temps d'exécution ne constitue pas une preuve. Et bien, c'est pour cela que nous avons utilisé une IP invalide 300.300.300.300
, car celle-ci ne peut être retournée que par le mock. Mais voyons comment mettre en place un test d'intégration que le service du client HTTP est bien décoré :
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Tests\Mock;
use App\Tests\Mock\AbstractApiMock;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class AbstractApiMockTest extends KernelTestCase
{
/**
* Test that the "abstract.api.client" service is decorated by the mock.
*/
public function testAbstractApiMockDecoration(): void
{
// this is the service ID of the HTTP client in the DI container
$abstractApiClientId = 'abstract.api.client';
// standart service (as it is in the prod env)
self::assertTrue(self::getContainer()->has($abstractApiClientId));
// decorated service
self::assertTrue(self::getContainer()->has(AbstractApiMock::class.'.'.$abstractApiClientId));
}
}
Quelques explication. On teste que le service original existe ; c'est celui utilisé en environnement de développement ou de production. Puis on teste le service "interne" injecté dans le mock à la décoration. C'est pourquoi, quand on configure le service App\Tests\Mock\AbstractApiMock
dans, on a concaténé ce même identifiant de service à l'identifiant du mock, à savoir abstract.api.client
. Vous avez sans doute remarqué que le mock n'accepte pas d'arguments dans son constructeur. C'est exact ; c'est parce que nous n'en avons pas besoin. Je l'ignore donc. Mais si l'on n'active pas cet argument, le service interne n'existerait pas et ce test ne pourrait pas fonctionner. OK, notre test marche. Nous pouvons commenter la déclaration du service App\Tests\Mock\AbstractApiMock
et relancer le test, il devrait donc échouer dans ce cas :
make test filter=AbstractApiMockTest PHPUnit 9.5.13 by Sebastian Bergmann and contributors. Testing F 1 / 1 (100%) Time: 00:02.076, Memory: 105.00 MB There was 1 failure: 1) App\Tests\Integration\Tests\Mock\AbstractApiMockTest::testAbstractApiMockDecoration Failed asserting that false is true. strangebuzz.com/tests/Integration/Tests/Mock/AbstractApiMockTest.php:24 FAILURES! Tests: 1, Assertions: 2, Failures: 1.
La seconde assertion qui teste le service interne échoue et vérifie donc bien ce que nous voulions 🎉.
Je vous propose un petit quiz pour finir, qui a dit :
“Pro tip: Stop mocking everything in unit tests… By doing so, you are not testing anything anymore. ”
Cliquez ici pour voir la réponse
Pro tip: Stop mocking everything in unit tests… By doing so, you are not testing anything anymore.
— Fabien Potencier (@fabpot) April 25, 2013
Conclusion
Nous avons vu une solution simple pour utiliser des mock de client HTTP dans des tests Symfony. Souvenez-vous que cela a un coût. On assume que le réponse de l'API tierce ne change pas. Si c'est le cas, les tests passeront toujours, mais le site plantera en production ! Dans le cas où vous voulez quelque chose de plus robuste et si vous avez besoin de gérer une multitude de mocks différents pour une même ressource, vous souhaiterez probablement utiliser un serveur de mocks comme Mockserver ou Wiremock. Voyons cela dans un prochain article (ou pas).
Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊
Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : alanpoulain. 👍
A vous de jouer !
Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )
- Me remonter des erreurs ou typos.
- Me remonter des choses qui pourraient être améliorées.
- Aimez et retweetez !
- Suivez moi sur Twitter Suivez moi sur Twitter
- Inscrivez-vous au flux RSS.
- Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.
Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉
[🇫🇷] Premier article de l'année : "Substitution simple d'API avec le client HTTP Symfony" https://t.co/4MbmGuyxMU Relectures, retours, likes et retweets sont les bienvenus ! 😉 Objectif annuel : 1 / 6 #symfony #tests #phpunit #mock #http #api
— COil #OnEstLaTech ✊ (@C0il) February 21, 2022