La pyramide des tests par la pratique (4/5)
Dans le dernier article, nous décrivions les tests de composant, tests mi-unitaires mi-intégration permettant de valider à la fois l’intégration au sein de notre application (injection de dépendances) et aussi avec les composants périphériques. Le tout en restant suffisamment isolés de ces derniers pour limiter les frottements à l’exécution. L’isolation fonctionnant à merveille, le test de client d’API souffre d’un défaut majeur : lorsque le fournisseur change la signature du service, nous en sommes au courant bien trop tard. C’est ce que les tests de contrats tentent de parer et que nous allons aborder dans cet article.
body .gist .highlight { background: #202020; } body .gist tr:nth-child(2n+1) { background: #202020; } body .gist tr:nth-child(2n) { background: #202020; } body .gist .gist-meta { display:none; } body .gist .blob-num, body .gist .blob-code-inner, body .gist .pl-s2, body .gist .pl-stj { color: #f8f8f2; } body .gist .pl-c1 { color: #ae81ff; } body .gist .pl-enti { color: #a6e22e; font-weight: 700; } body .gist .pl-st { color: #66d9ef; } body .gist .pl-mdr { color: #66d9ef; font-weight: 400; } body .gist .pl-ms1 { background: #fd971f; } body .gist .pl-c, body .gist .pl-c span, body .gist .pl-pdc { color: #75715e; font-style: italic; } body .gist .pl-cce, body .gist .pl-cn, body .gist .pl-coc, body .gist .pl-enc, body .gist .pl-ens, body .gist .pl-kos, body .gist .pl-kou, body .gist .pl-mh .pl-pdh, body .gist .pl-mp, body .gist .pl-mp1 .pl-sf, body .gist .pl-mq, body .gist .pl-pde, body .gist .pl-pse, body .gist .pl-pse .pl-s2, body .gist .pl-mp .pl-s3, body .gist .pl-smi, body .gist .pl-stp, body .gist .pl-sv, body .gist .pl-v, body .gist .pl-vi, body .gist .pl-vpf, body .gist .pl-mri, body .gist .pl-va, body .gist .pl-vpu { color: #66d9ef; } body .gist .pl-cos, body .gist .pl-ml, body .gist .pl-pds, body .gist .pl-s, body .gist .pl-s1, body .gist .pl-sol { color: #e6db74; } body .gist .pl-e, body .gist .pl-ef, body .gist .pl-en, body .gist .pl-enf, body .gist .pl-enm, body .gist .pl-entc, body .gist .pl-entm, body .gist .pl-eoac, body .gist .pl-eoac .pl-pde, body .gist .pl-eoi, body .gist .pl-mai .pl-sf, body .gist .pl-mm, body .gist .pl-pdv, body .gist .pl-som, body .gist .pl-sr, body .gist .pl-vo { color: #a6e22e; } body .gist .pl-ent, body .gist .pl-eoa, body .gist .pl-eoai, body .gist .pl-eoai .pl-pde, body .gist .pl-k, body .gist .pl-ko, body .gist .pl-kolp, body .gist .pl-mc, body .gist .pl-mr, body .gist .pl-ms, body .gist .pl-s3, body .gist .pl-smc, body .gist .pl-smp, body .gist .pl-sok, body .gist .pl-sra, body .gist .pl-src, body .gist .pl-sre { color: #f92672; } body .gist .pl-mb, body .gist .pl-pdb { color: #e6db74; font-weight: 700; } body .gist .pl-mi, body .gist .pl-pdi { color: #f92672; font-style: italic; } body .gist .pl-pdc1, body .gist .pl-scp { color: #ae81ff; } body .gist .pl-sc, body .gist .pl-sf, body .gist .pl-mo, body .gist .pl-entl { color: #fd971f; } body .gist .pl-mi1, body .gist .pl-mdht { color: #a6e22e; background: rgba(0, 64, 0, .5); } body .gist .pl-md, body .gist .pl-mdhf { color: #f92672; background: rgba(64, 0, 0, .5); } body .gist .pl-mdh, body .gist .pl-mdi { color: #a6e22e; font-weight: 400; } body .gist .pl-ib, body .gist .pl-id, body .gist .pl-ii, body .gist .pl-iu { background: #a6e22e; color: #272822; } body .gist .gist-file, body .gist .gist-data { border: 0px; border-bottom: 0px; }
Tests de contrats
Que tester ?
Lorsqu’on parle d’architecture microservice, il est fort probable que les services communiquent entre eux et on court à la catastrophe si l’on n’adopte pas certains patterns et notamment une parfaite autonomie :
- Shared nothing architecture pour la partie technique
- Autonomie de l’équipe pour la partie organisationnelle
- Autonomie des déploiements pour la partie process.
C’est sur ce dernier point que les tests de contrats vont nous aider. Il s’agit d’un excellent complément aux tests de composants lorsqu’on parle de (micro)services. En effet, les tests de contrats vont permettre de vérifier que le contrat entre un fournisseur et un consommateur est toujours celui initialement défini entre les deux parties :
- Du côté du fournisseur, on vérifie que l’on fournit bien le service que les consommateurs s’attendent à avoir (l’implémentation du contrat). Si ce n’est pas le cas, c’est que notre interface a changé, volontairement ou non. Si c’est involontaire, le test échouera et nous évitera de déployer un service qui ferait planter tous les consommateurs. Si c’est volontaire, alors il est nécessaire de le communiquer aux différents consommateurs et de monter la version du service.
- Du côté du consommateur, on vérifie que le contrat n’a pas changé. S’il a changé on vérifie auprès du fournisseur ce qu’il en est et on adapte notre code (le Client) et les tests de Composant associés le cas échéant.
On entend souvent parler de Consumer-Driven Contracts, un pattern qui vise à co-construire les contrats à partir des besoins des consommateurs plutôt que de leur imposer celui définit par le fournisseur. Cela afin de limiter les frottements lors d’évolutions du service (gestion de la compatibilité montante et descendante, versions, ... généralement imposé par le fournisseur), mais également pour mieux connaître l’usage réel qui est fait du service (quels endpoints, quelles valeurs sont utiles).
Voilà donc ce que cela donnerait dans notre exemple :
Implémentation
Pour implémenter ce type de tests, je suis resté dans la nébuleuse Spring en utilisant Spring Cloud Contract, mais il existe d’autres frameworks et notamment l’un des plus connus, Pact.
Faisons donc un zoom sur ces tests :
Côté fournisseur
Configuration Maven
Côté fournisseur nous aurons besoin de deux choses :
- La librairie permettant de vérifier que notre controller implémente bien le contrat :
- Le plugin qui va permettre de générer les tests à partir du contrat et de générer les stubs qui seront utilisés côté consommateur :
La baseClassForTests
est une classe mère qui sera utilisée par tous les tests générés, nous y reviendrons.
Définition du contrat
La première étape vise donc à créer un contrat (au format yaml ou avec un DSL groovy), dans lequel on décrit la requête et la réponse. L’exemple suivant est simpliste, il est possible de faire des choses bien plus poussées (notamment avec des expressions régulières ou autres, je vous invite à lire la doc). On stockera ce contrat côté fournisseur dans le répertoire src/test/resources/contracts, avec le corps de la réponse, également dans un fichier :
À ce moment si vous exécutez Maven, le mécanisme va se mettre en place… ou du moins en partie. La classe de test va être générée (dans target/generated-test-sources/contracts) :
Mais le test va échouer…
Classe mère des tests de contrat
En effet, nous avons besoin de la classe de base dont nous parlions plus haut pour initialiser un contexte et faire en sorte que le test passe :
Pas mal de choses à ce niveau :
- L’objectif premier est de fournir le nécessaire pour que le Controller puisse répondre au test. On fournit donc un environnement MockMvc grâce au RestAssuredMockMvc.
- Nous pourrions nous contenter de la dernière ligne mais dans ce cas, le Controller utiliserait la vraie implémentation du Service, lui-même utilisant le vrai client qui appellerait alors l’open API de transport. Pour éviter cela (isolation encore et toujours), nous faisons comme précédemment en fournissant un MockBean qui va simuler le retour du Service en désérialisant un fichier json (identique au fichier utilisé dans le contrat).
- Il faudra généralement créer autant de classes de base que vous avez de Controllers, pour leur fournir un comportement. La configuration maven est quelque peu différente dans ce cas.
À ce moment-là, les tests côté fournisseur devraient passer, et si vous avez lancé un mvn install ou deploy, le jar contenant les stubs devrait être publié dans le repo Maven (local ou distant) :
Installing connection-lookup/target/connection-lookup-0.0.1-SNAPSHOT-stubs.jar to ~/.m2/repository/ch/octo/blog/connection-lookup/0.0.1-SNAPSHOT/connection-lookup-0.0.1-SNAPSHOT-stubs.jar
Passons à présent côté consommateur (notre module journey-booking).
Côté consommateur
Configuration Maven
Nous avons ici besoin de la dépendance permettant d’utiliser le stub généré côté fournisseur :
Par ailleurs, il nous faut remplacer la dépendance wiremock avec celle de Spring Cloud sous peine d’avoir une exception. Si vous vous souvenez de notre test du client, nous utilisions wiremock pour simuler un serveur. Spring Cloud fait de même mais se base sur le stub auto-généré, ce qui permet d’être aligné avec le contrat du fournisseur.
Le test
Le code est le suivant (lien gitlab) :
Le test est relativement proche de notre test de composant (ConnectionLookupClientCompTest), à ceci près que nous ne manipulons plus wiremock directement. C’est l’annotation @AutoConfigureStubRunner
qui va configurer le stub, en allant chercher la dépendance Maven spécifiée par l’attribut ids (au format groupId:artifactId:version:classifier
) et en l’exposant sur le port 8090.
En utilisant le + comme numéro de version, on s’assure de prendre la plus récente, ce qui permet de vérifier que notre test passe toujours malgré les évolutions du fournisseur. Le jour où le test ne passe plus, nous savons que le contrat a changé, logiquement avant que le fournisseur ait mis en production la nouvelle version...
Test de contrat ou test de composant ?
Comme nous venons de le voir, le test de contrat n’est autre qu’un test de composant, mais il a en plus l’avantage de valider que fournisseur et consommateur(s) sont toujours alignés. Je ne saurais donc que recommander d’utiliser ce dernier, en tout cas dans un environnement maîtrisé (typiquement des microservices au sein de votre entreprise). Cela n’a pas de sens lorsqu’on utilise une open API (typiquement l’API de transport), dans ce cas, on restera sur un test de composant.
Dans les 2 cas, je considère ces tests suffisamment importants et relativement rapides à exécuter pour être intégrés au build continu, encore et toujours dans l’objectif d’avoir une feedback rapide.
Les tests de contrats, souvent associés au pattern Consumer-Driven Contracts, sont donc un excellent moyen de vérifier que consommateur et fournisseur d’un service (qu’il soit REST ou via un message) sont toujours alignés sur un contrat commun et partagé. Ils ont également l’avantage de s’exécuter assez rapidement (isolation grâce à wiremock) et donc intégrables à la chaîne d’intégration continue. Dans le prochain article, nous aborderons des tests beaucoup moins simples à exécuter puisqu’il s’agit des tests d’intégration et tests de bout en bout.