Écrit par Simon D.
Dans nos applications nous avons très souvent différents tests : unitaires, intégrations et end-to-end, mais ils ne vérifient que le comportement de notre service.
Les tests end-to-end quant à eux arrivent généralement sur la fin de nos développements, sont très longs à jouer, très lourds à maintenir et très coûteux.
Dans ces conditions nous avons une chance d’avoir un problème.
En effet, un point important à prendre en compte dans les architectures microservices est la communication entre les services.
Cependant, ce point n’est pas ou peu pris en compte avec les tests habituels pendant le développement. C’est là que le test de contrat (contract testing) entre en jeu.
Qu’est-ce qu’un test de contrat ?
L’idée principale, c’est que lorsqu’une application ou un service (consommateur) consomme une API fournie par un autre service (fournisseur), un contrat est conclu entre eux.
Les différents services conviennent d’un ensemble de règles et de spécifications : définition des entrées, des sorties et des comportements attendus.
Avec un contrat commun on peut vérifier et valider les interactions entre différents services en se basant sur celui-ci.
Cela signifie que les services ne sont plus complètement isolés les uns des autres mais ne sont pas directement connectés non plus, comme cela se produit avec les tests end-to-end.
Au lieu de cela, ils sont indirectement connectés et communiquent entre eux en utilisant les contrats comme un outil.
Mais, qui fait quoi dans cette histoire ?
Il est important de savoir qu’il y a plusieurs manières de mettre en place ce type de tests.
Les deux principales sont les tests pilotés par le consommateur et les tests pilotés par le fournisseur.
La principale différence entre eux est le mode de coopération :
- Dans les tests pilotés par le fournisseur, celui-ci définit les contrats, rédige les tests du contrat et d’écrit l’API. Souvent, c’est utile lorsque l’API est publique et que le propriétaire de l’API ne sait même pas qui l’utilise exactement.
- Dans les tests pilotés par le consommateur, c’est lui qui définit les contrats en coopération avec le fournisseur. Le fournisseur sait exactement quel consommateur a défini quel contrat et lequel est rompu lorsque la compatibilité du contrat est rompue.
Cette approche est plus courante lorsqu’on travaille avec une API interne.
Place à l’exemple
Après cette rapide présentation sur les tests de contrat de manière générale, regardons d’un peu plus près comment fonctionnent ces tests quand ils sont pilotés par le consommateur.
Pour l’article j’utilise Pact, c’est un outil qui va aider à la gestion de contrat entre le consommateur et le fournisseur et qui prend en charge de nombreux langages.
Il en existe d’autres, par exemple Spring Cloud Contract , Karate DSL, etc.
Comment fonctionnent les tests pilotés par le consommateur avec PACT dans notre application
Pour vérifier que les contrats sont valides, Pact implémente les tests de contrat via un processus impliquant à la fois le consommateur et le fournisseur d’un service.
Dans l’exemple qui suit nous allons voir les différentes étapes mises en place pour vérifier les échanges entre deux applications.
Cet exemple est basé sur Pact-Ruby mais le principe est le même pour tous les autres langages. Vous pouvez d’ailleurs retrouver l’exemple complet directement sur le github de Pact).
Test côté consommateur :
- Le consommateur écrit un test pour l’interaction attendue avec le service du fournisseur.
# zoo-app/spec/service_providers/animal_service_client_spec.rb
context "when an alligator by the given name exists" do
before do
animal_service.given("there is an alligator named Mary").
upon_receiving("a request for an alligator").with(
method: :get,
path: '/alligators/Mary',
headers: {'Accept' => 'application/json'} ).
will_respond_with(
status: 200,
headers: {'Content-Type' => 'application/json;charset=utf-8'},
body: {name: 'Mary'}
)
end
it "returns the alligator" do
expect(AnimalServiceClient.find_alligator_by_name("Mary")).to eq ZooApp::Animals::Alligator.new(name: 'Mary')
end
end
Exemple de test pour vérifier que le GET /alligators/Mary
nous retourne bien un alligator avec un champ name
, un statut 200 et un header défini
Génération du Fichier Pact :
- Lorsque les tests du consommateur réussissent, le fichier Pact (contrat) est généré. Ce fichier inclut les requêtes définies et les réponses attendues.
Les tests sont réussis, un fichier est généré directement dans /spec/pacts/
, l’emplacement par défaut
# zoo-app/spec/pacts/zoo_app-animal_service.json
{
"consumer": {
"name": "Zoo App"
},
"provider": {
"name": "Animal Service"
},
"interactions": [
{
"description": "a request for an alligator",
"providerState": "there is an alligator named Mary",
"request": {
"method": "get",
"path": "/alligators/Mary",
"headers": {
"Accept": "application/json"
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json;charset=utf-8"
},
"body": {
"name": "Mary"
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "2.0.0"
}
}
}
Exemple de fichier généré par Pact qui va servir de contrat pour le fournisseur.
Vérification par le fournisseur :
- Le fournisseur utilise ensuite ce fichier Pact et le teste avec son propre service pour vérifier que celui-ci répond aux attentes du contrat.
# animal-service/spec/service_consumers/pact_helper.rb
require 'pact/provider/rspec'
require "./spec/service_consumers/provider_states_for_zoo_app"
Pact.service_provider 'Animal Service' do
honours_pact_with "Zoo App" do
pact_uri '../zoo-app/spec/pacts/zoo_app-animal_service.json'
end
end
On indique où trouver les contrats
# animal-service/spec/service_consumers/provider_states_for_zoo_app.rb
Pact.provider_states_for "Zoo App" do
set_up do
AnimalService::DATABASE[:animals].truncate
end
provider_state "there is an alligator named Mary" do
set_up do
AnimalService::DATABASE[:animals].insert(name: 'Mary')
end
end
end
Pact est aussi capable de faire des actions avant de jouer le contrat en fonction du providerState
disponible dans le contrat
- Les tests du fournisseur comparent chaque requête enregistrée dans le fichier Pact avec la réponse réelle que le service fournit. Si elles correspondent, le fournisseur est considéré comme respectant le contrat.
Test réussi du côté du fournisseur
Imaginons maintenant que notre consommateur attend en plus du nom, l’âge.
# zoo-app/spec/service_providers/animal_service_client_spec.rb
will_respond_with(
status: 200,
headers: {'Content-Type' => 'application/json;charset=utf-8'},
body: {name: 'Mary', age: 10}
)
Nous ajoutons le champ age
dans la réponse attendue
Si nous rejouons le test du côté fournisseur sans ajouter le champ age
à notre réponse, le test va échouer. Le contrat avec le consommateur est rompu.
Conclusion
Les tests de contrat sont un bon moyen de fiabiliser et stabiliser une application en s’assurant que les échanges entre les services ont le comportement attendu et aident à la découverte des problèmes entre les services pendant le développement.
Ils obligent par contre à avoir une bonne collaboration entre les services, étant donné que les consommateurs et le fournisseur doivent s’entendre sur un contrat.
Le sujet des tests de contrat est tellement vaste qu’il y a évidemment beaucoup d’aspects que je n’ai pas abordés dans cet article, comme la gestion des contrats dans un pact broker, l’intégration continue, la possibilité publication des résultats pour le suivi des vérifications ou encore la gestion de version des contrats.
Si vous êtes intéressé par tous ces sujets, la doc Pact est très bien faite avec des exemples pour de nombreux langages.