Comment tester une API externe en ayant 0 Mocks ?

EZZINE Imen

 Développeuse Backend PHP Symfony

 

@imenezzine

  @imenezzine1

 

I.Ezzine
I.Ezzine

 Comment tester une application consommant une API externe ?

I.Ezzine
  • Utilisation de l'api réelle
  • Sandbox ou environement dédié
  • Conteneurisation Api en local
  • Avec mock

 

Les possibilités

I.Ezzine

Utilisation de l'api réelle

  • Facturation à l'appel


  • Le service externe est indisponible ?


  • L'abonnement à l'API est expiré ?


  • Limité à les endpoints GET


I.Ezzine

Sandbox

  • Des tests qui dépendent de l'extérieur, peuvent échouer si la sandbox ne répond plus
  • Tests plus lent à exécuter
  • Appels externes
I.Ezzine

Virtualisation

 

  • Configuration complexe
  • Données potentiellement erronées
  • Couverture des cas limitée
I.Ezzine

Avec Mock

  • Décorrélation
  • Risque de divergence entre le Mock et l'API réelle
  • Encombrement du code avec des détails de simulation
  • Maintenance du Mock en cas de changements dans l'API
I.Ezzine
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$responses = [
	new MockResponse ($body1, $info1),
    new MockResponse ($body2, $info2)
];

$client = new MockHttpClient($responses);
// responses in the same order as passed to MockHttpClient
$response1 = $client->request('...');
$response1 = $client->request('...');

Définir les réponses à la main

use Symfony\Component\HttpClient\Response\JsonMockResponse;

$response = new JsonMockResponse([
    'foo' => 'bar',
]);
I.Ezzine
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Psr\Log\NullLogger;

class AuthenticatedClientTest extends TestCase
{
    const BASE_AUTH_URI = 'https://example.com/auth';
    const BASE_REST_URI = 'https://example.com/rest';

    public function testRequestWithExpiredToken(): void
    {
        $authenticatedClient = new AuthenticatedClient(
            new MockHttpClient([
                function ($method, $url, $options): MockResponse {
                    $this->assertSame('POST', $method);
                    $this->assertSame(sprintf('%s/v2/token', self::BASE_AUTH_URI), $url);
                    $this->assertSame('{"grant_type":"client_credentials","client_id":"clientId","client_secret":"clientSecret","account_id":"accountId"}', $options['body']);

                    return new MockResponse('{"access_token":"expired_access_token"}', [
                        'http_code' => 200,
                    ]);
                },
                function ($method, $url, $options): MockResponse {
                    $this->assertSame('GET', $method);
                    $this->assertSame(sprintf('%s/whatever', self::BASE_REST_URI), $url);
                    $this->assertArrayHasKey('headers', $options);
                    $this->assertArrayHasKey('normalized_headers', $options);
                    $this->assertArrayHasKey('authorization', $options['normalized_headers']);
                    $this->assertArrayHasKey(0, $options['normalized_headers']['authorization']);
                    $this->assertSame('Authorization: Bearer expired_access_token', $options['normalized_headers']['authorization'][0]);

                    return new MockResponse('', [
                        'http_code' => 401,
                    ]);
                },
                function ($method, $url, $options): MockResponse {
                    $this->assertSame('POST', $method);
                    $this->assertSame(sprintf('%s/v2/token', self::BASE_AUTH_URI), $url);
                    $this->assertSame('{"grant_type":"client_credentials","client_id":"clientId","client_secret":"clientSecret","account_id":"accountId"}', $options['body']);
                    return new MockResponse('{"access_token":"new_access_token"}', [
                        'http_code' => 200,
                    ]);
                },
                function ($method, $url, $options): MockResponse {
                    $this->assertSame('GET', $method);
                    $this->assertSame(sprintf('%s/whatever', self::BASE_REST_URI), $url);
                    $this->assertArrayHasKey('headers', $options);
                    $this->assertArrayHasKey(0, $options['normalized_headers']['authorization']);
                    $this->assertSame('Authorization: Bearer new_access_token', $options['normalized_headers']['authorization'][0]);

                    return new MockResponse('', [
                        'http_code' => 201,
                    ]);
                },
            ]),
            'clientId',
            'clientSecret',
            self::BASE_AUTH_URI,
            'accountId',
            new NullLogger()
        );

        $authenticatedClient->request('GET', '/whatever');
    }
}
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'un  Sandbox -Isolation des tests
-Fiabilité des résultats
-Limitations de fonctionnalités
-Dépendance externe
Utilisation de Mock -Contrôle total
-Rapidité
-Écart par rapport à la réalité
-Maintenance ²
Conteneurisation de l'API en Local -Isolation des tests
- Réalisme des tests
- Configuration complexe
-Dépendance des ressources locales
I.Ezzine

Qu'est ce qu'on cherche ?

  • Moins de code possible ?
  • Moins de configuration ou plutôt automatisation ?
  • Moins de maintenabilité ?
  • Décorrélation complète ?

 

                                                                                                            

I.Ezzine

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

 

 

I.Ezzine

Mais vous pensez VRAIMENT que c'est possible de tester une API externe avec 0 mock configuré ?

 

I.Ezzine
I.Ezzine

Magnétoscope (Video Cassette Recorder)

Les solutions existantes

 

I.Ezzine

 En chiffres

I.Ezzine

Comment ça marche ?

I.Ezzine

Un peu de Théorie

I.Ezzine

Comment ça marche ?

Surcharge des fonctions PHP(fopen(), curl_exec, file_get_contents()...)

Récupérer tous  les détails de la requête
(metadata)

Exécuter la vrai requête

Récupérer  tous les

détails de la réponse

Storage

Requête

Réponse

Comment procède t-on ?

Client unique de base

\VCR\VCR::configure()
->setCassettePath(base_path().'/tests/cassettes');

Chemin de stockage

\VCR\VCR::class;

Mode

//record
\VCR\VCR::configure()->setMode('new_episodes'); 
//record and replay
\VCR\VCR::configure()->setMode('once'); 

On

Eject

Off

\VCR\VCR::turnOn();
\VCR\VCR::turnOff();
\VCR\VCR::eject();
\VCR\VCR::configure()->setStorage('json');

Format de stockage

I.Ezzine

Comment procède t-on ?

\VCR\VCR::configure()->enableRequestMatchers(['method', 'url', 'host']);
  • Request Matching
\VCR\VCR::configure()
    ->addRequestMatcher(
        'custom_matcher',
        function (\VCR\Request $first, \VCR\Request $second) {
            // custom request matching
            return true;
        }
    )
    ->enableRequestMatchers(['method', 'url', 'custom_matcher']);
  • Custom request matching
I.Ezzine
  • StreamWrapper
  • SoapClient
  • cUrl
\VCR\VCR::configure()->enableLibraryHooks(['curl'])

Comment procède t-on ?

  • Library hooks

Avec PHPUnit?

class VCRTest extends TestCase
{
    /**
     * @vcr unittest_annotation_test
     */
    public function testInterceptsWithAnnotations()
    {
        // Requests are intercepted and stored into 
        // tests/fixtures/unittest_annotation_test.
        $result = file_get_contents('http://google.com');

        $this->assertEquals(
        	'This is a annotation test dummy.',
             $result,
            'Call was not intercepted (using annotations).'
        );

        // VCR is automatically turned on and off.
    }
}

PHPUnit TestListener for PHP-VCR      phpunit-testlistener-vcr

Pourquoi l'utiliser ?

  • Tests beaucoup plus rapides
  • Indépendants de l'API
  • Inspection des requêtes HTTP
  • Tests déterministes (pas de rate limits, erreurs externes ou comportements inattendus)
  • Vérification des changements dans les API externes (contrôle de version des enregistrements)
  • Détection des requêtes HTTP inattendues
  • On ne dépend plus d’appels réseaux
  • Mise à jour simplifié 
  • Aucun mock manuel
I.Ezzine

Exemple Concret

I.Ezzine
I.Ezzine

Text

I.Ezzine

Comment ça marche ?

  @vcr
  Scenario: retrieve a product
    Given 1 product with category "category_1" exist in the PIM
    When I retrieve the product from the pim with the root category "test"
    Then I should get the expected pim product
I.Ezzine
# config/tests/boostrap.php

use Symfony\Component\Dotenv\Dotenv;

require dirname(__DIR__).'/../vendor/autoload.php';
require __DIR__.'/VCRMatcher/CustomBodyMatcher.php';

\VCR\VCR::configure()
    ->addRequestMatcher(
        'custom_body_matcher',
        new CustomBodyMatcher()
    )
    ->enableRequestMatchers(['method', 'url', 'custom_body_matcher', 'post_fields'])
    ->enableLibraryHooks(['curl'])
    ->setCassettePath(dirname(__DIR__, 2).'/tests/Environment/IO/cassettes')
    ->setStorage('yaml')
    ->setMode('once');

// Turn on for Initializing VCR
\VCR\VCR::turnOn();

// Turn off for DI
\VCR\VCR::turnOff();

(new Dotenv())->bootEnv(dirname(__DIR__).'/../.env');

Un peu de config !

I.Ezzine

La magie de PHP-VCR

# tests/MockServerContexte.php

private bool $vcrOn = false;

/**
 * @BeforeScenario
 */
public function setupVCR(BeforeScenarioScope $scenarioEvent): void
{
    if ($scenarioEvent->getScenario()->hasTag('vcr')) {
        \VCR\VCR::turnOn();
        $this->vcrOn = true;
        $scenarioCassette = strtolower(
            str_replace(' ', '_', $scenarioEvent->getScenario()->getTitle())
        );
        $suiteName = $scenarioEvent->getSuite()->getName();
        \VCR\VCR::insertCassette($suiteName.'/'.$scenarioCassette.'.yaml');
    }
}
    
/**
 * @AfterScenario
 */
public function ejectCassette(): void
{
    if ($this->vcrOn) {
        \VCR\VCR::eject();
    }
    \VCR\VCR::turnOff();
}
I.Ezzine

 Comment ça se présente ?

-
    request:
        method: POST
        url: 'https://api.example.com/images'
        headers:
            Host: api.example.com
            Accept-Encoding: ''
            X-Auth-Client: your_auth_client
            X-Auth-Token: your_auth_token
            Accept: application/json
            User-Agent: GuzzleHttp/7
            Content-Type: 'multipart/form-data; boundary=your_boundary'
        body: !!binary |
            LS00NGIxNzhkZmRkZTFhNzQ1YWNjZjMwMTM4MmUzOWY4MTUxOTBjYzc1DQ
            pDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImltYWdlX
            2ZpbGUiOyBmaWxlbmFtZT0iaWNvbi5wbmciDQpDb250ZW50LUxlbmd0aDogMzEwDQp
            Db250ZW50LVR5cGU6IGltYWdlL3BuZw0KDQqJUE5HDQoaCgAAAA1JSERSAAAAEAAAABAIBgA
            AAB/z/2EAAAAJcEhZcwAACxIAAAsSAdLdfvwAAADoSURBVDiNpZPRDAAAElFTkSuQmCC
    response:
        status:
            http_version: '1.1'
            code: '200'
            message: OK
        headers:
            Content-Type: application/json
            Transfer-Encoding: chunked
            Connection: keep-alive
            Vary: Accept-Encoding
            X-Request-ID: 838d78668230cc576f6bd482d1229b45
            X-Rate-Limit-Requests-Left: '16101494'
            X-Rate-Limit-Time-Reset-Ms: '29999'
            X-Rate-Limit-Requests-Quota: '16101495'
            X-Rate-Limit-Time-Window-Ms: '30000'
            Strict-Transport-Security: 'max-age=31536000; includeSubDomains'
        body: '{"data":{"id":1,"product_id":2,"is_thumbnail":false,"sort_order":1,"description":"",
        "image_file":"y\/550\/icon__19355.png",
        "url_zoom":"https:\/\/cdn11.bigcommerce.com\/s-sxuyyapoo3\/products\/493\/images\
        /426\/icon__19355.1637747487.1280.1280.png?c=1","url_standard":"https:\/\/cdn11.bigcommerce.com\/s-

 Comment maintenir ça avec le temps ?

  • Mises à jour régulières
  • Versionning des cassettes
  • Nommage sémantique des cassettes
I.Ezzine

PHP-VCR est un outil puissant pour simplifier les tests d’intégration en enregistrant et en rejouant des requêtes HTTP.

Que vous choisissiez d’utiliser les méthodes statiques ou de l’intégrer avec PHPUnit, PHP-VCR peut grandement améliorer la stabilité et la cohérence de vos tests en réduisant la dépendance aux services externes.

En l’utilisant judicieusement, vous pouvez rendre vos tests plus fiables et plus rapides.

 

Conclusion

 

I.Ezzine

Merci

Questions ?

I.Ezzine

SymfonyLive

By imenezzine

SymfonyLive

  • 377