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
- Guzzle VCR https://github.com/dshafik/guzzlehttp-vcr
- PHP-HTTP VCR Plugin https://github.com/php-http/vcr-plugin

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
- 816