Substitution simple d'API avec le client HTTP Symfony

Publié le 12/02/2022 • Mis à jour 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 ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

» 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.0
  • Symfony 5.4.8
  • PHPUnit 9.5.10

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
 */
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.


Barbara Liskov
Barbara Liskov

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());
        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

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. 😊

  Lire la doc  Plus sur le web

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
  • 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 ! 😉

COil