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