Introduction
Lorsque nous avons rejoint notre client sur son projet, nous avons embarqué dans un train agile regroupant plus de 15 équipes ayant pour objectif de déployer à terme entre 30 et 50 micro-services. Nous avons rapidement identifié
un point d'attention majeur lié aux liens entre applications. En effet, si les dépendances avec les systèmes externes sont nombreuses, les interdépendances entre équipes - et entre applications au sein de ces équipes
- le sont encore plus.
Dès le premier sprint, le besoin de bouchonner les applications (mock) a émergé, et ce afin de répondre à plusieurs enjeux critiques :
- Garantir la confidentialité
des données et éviter l'utilisation de données réelles (risque lié aux Données à Caractère Personnel/RGPD)
- Assurer la cohérence et la synchronisation des bouchons partagés
entre plusieurs équipes
- Limiter les approches locales ou non versionnées, synonymes d’incohérences, de duplication et de perte de temps
Cette situation nous a conduit à adopter une stratégie de
bouchonnage d'API robuste, versionnée et centralisée, capable de soutenir la collaboration inter équipes tout en répondant aux exigences de sécurité et de fiabilité.
Bénéfices attendus
L'adoption d'une telle stratégie offre de nombreux avantages :
- Accélération des processus de développement et de test en réduisant les retards causés par les dépendances internes et externes
-
Réduction de la dépendance par rapport aux applications externes et aux autres produits indisponibles ou instables
- Partage des informations des bouchons et création de scénarios déterministes
- Limitation
des anomalies liées aux jeux de données lors des tests d'intégration
Le gain majeur consiste en une qualité accrue lors des tests d'intégration et de recette et in-fine, un time-to-market réduit.
Exigences de la solution
La solution mise en place se doit de respecter plusieurs exigences :
- La solution permet de bouchonner des API REST et des services web SOAP
- La solution permet de configurer des bouchons dynamiques (c'est à dire dont la réponse
dépend de la requête)
- La solution permet à tous les profils (développeurs, experts métiers, testeurs...) de rédiger des définitions de bouchons
- La solution permet de partager les définitions
des bouchons, de les centraliser et de les versionner
- La solution permet aux équipes responsables de bouchons d'effectuer un contrôle ou des revues des bouchons avant que ceux-ci ne soient poussés vers les environnements
de recette
- La solution permet le déploiement de ces définitions de bouchons dans un environnement de façon simple et sans avoir de compétences techniques particulières ("one click")
La solution
Le premier composant à évaluer est le serveur de mocks en lui-même. Pour des raisons de cout et de simplicité de maintenance, nous avons fait le choix de prendre un produit du marché plutôt que de développer
une solution adhoc.
Trois produits semblaient répondre à nos besoins :
- CastleMock
- WireMock
- Mikrocks
Leurs fonctionnalités principales sont résumées ci-dessous :
Fonctionnalité | CastleMock | WireMock | Mikroks |
---|
Types de bouchons | REST et SOAP | REST, SOAP, GraphQL (via une extension), gRPC (via une extension) | REST, SOAP, GraphQL, gRPC |
IHM | Basique | Oui via des extensions | Complète |
Déploiement | Déployé dans Tomcat ou Docker (un seul conteneur) | Standalone, embarqué dans le code Java ou Docker (un seul conteneur) | Docker/Kubernetes (multiples conteneurs) |
Réponses dynamiques | Oui | Templating et réponses dynamiques | Templating et réponses dynamiques |
Scénarios de test | Pas de scénarios statefull | Possibilité de définir des scénarios statefull (via une extension) | Oui, avec des "stateful mocks" |
Intégration | Limitée | Etendue : intégration native dans les tests unitaires et les TI, administrable via une API | Etendue : dans Jenkins, via une API ou une CLI |
Support | Communauté réduite | Communauté très importante, support commercial possible | Communauté importante, projet de la Cloud Native Computing Foundation sandbox |
Persistance | Sur le système de fichiers | Sur le système de fichiers | Dans MongoDB |
Personnalisation | Limitée | Nombreuses extensions, possibilité d'ajouter des métadonnées dans les définitions des bouchons | Importante |
Difficulté de prise en main | Aisée : démarrage rapide | Modérée : nécessite du temps pour aborder les fonctionnalités avancées | Importante |
Si on met de côté une prise en main relativement compliquée, Mikroks semblait être le bon choix. Pour autant, nous avons retenu
Wiremock car :
- Il répond à nos exigences (REST, SOAP, réponses dynamiques)
- Il est relativement facile à prendre en main
- Il est facile à déployer
- Il permet de pousser les définitions des bouchons au format JSON via une API, et donc :
-
De stocker ces définitions au format JSON
- D'automatiser les déploiements
- Il est déjà utilisé par les équipes
de développement et intégré dans les tests d'intégration automatisés en Java
Ce dernier critère est important car il autorise la réutilisation des bouchons depuis les phases de test d'intégration
jusqu'aux recettes utilisateur sans avoir à réécrire les jeux de données.
Le reste de la solution est basé sur les composants suivants, déjà en place dans le SI du client :
-
Gitlab pour centraliser les définitions des bouchons. Tout comme pour le code applicatif, Gitlab permet d'appliquer un modèle de branching
adapté (gitflow / trunk based...), de suivre les modifications apportées aux définitions des bouchons et de faciliter le travail de contrôle à l'aide des Merge Request
-
Jenkins pour automatiser le déploiement des définitions de bouchons.
Installer localement Wiremock
Wiremock est disponible sous forme d'image Docker (https://hub.docker.com/r/wiremock/wiremock). Il est donc aisé de démarrer une instance de test :
docker run -it --rm \
-p 8081:8080 \
--name wiremock \
wiremock/wiremock:3.10.0
En production, il faudra être attentif au système de fichier sur lequel seront persistées les définitions des bouchons, mais en local, cette commande suffit pour tester la solution.
Un des manques de Wiremock
est son absence d'IHM. Plusieurs alternatives existent, et celle de
holomekc (https://github.com/holomekc/wiremock) semble répondre à nos besoins
: elle est simple, rapide et maintenue. Par ailleurs, elle est également disponible sous la forme d'une image Docker unique intégrant à la fois Wiremock et l'IHM, simplifiant au maximum le déploiement de la solution.
docker run -it --rm \
-p 8081:8080 \
--name wiremock \
wiremock/wiremock-gui:3.10.0
L'IHM est accessible dès le démarrage du conteneur et se trouve à l'adresse
http://localhost:8081/__admin/webapp/
Bouchons et API dans Wiremock
Bouchons
Wiremock permet de stocker les bouchons de façon unitaire sous forme de stub (un "morceau") dans un fichier JSON. Ainsi, le fichier suivant permet de définir un mock qui retourne {"response": "Hello world"} lorsqu'on invoque
l'URL /helloworld :
{
"mappings": [
{
"name": "hello world",
"request": {
"url": "/helloworld",
"method": "GET"
},
"response": {
"status": 200,
"jsonBody": {"response": "Hello world"},
"headers": {}
},
"persistent": true,
"priority": 5
}
],
"meta": {
"total": 1
}
}
Il est possible de définir plusieurs mocks par fichier en ajoutant des éléments dans le tableau mappings.
NOTE:
Il est important que les bouchons respectent les contrats d'interface (OpenAPI, WSDL...) des applications bouchonnées. Sans cela, les tests effectués dans les environnements bouchonnés ne seraient plus pertinents et mettraient
en risque la solution lors du passage en production..
API
Le serveur Wiremock expose une API d'administration décrite ici : https://wiremock.org/docs/standalone/admin-api-reference/, dont quelques opérations
permettent d'automatiser le déploiement des mocks :
Opération | Description |
`POST` `/__admin/mappings/import` | Créé un nouveau moc |
`DELETE` `/__admin/mappings` | Supprime tous les mocks |
`POST` `/__admin/mappings/save` | Persiste tous les mocks |
Il est donc aisé de scripter une purge des mocks puis l'injection d'un ou plusieurs fichiers et de les persister :
# Purger tous les mocks
curl -X DELETE http://localhost:8081/__admin/mappings
# Injecter le mock 'helloworld'
curl -v -d @./hello-world.json http://localhost:8081/__admin/mappings/import
# Persister les mocks
curl -X POST http://localhost:8081/__admin/mappings/save
Structure du dépôt Git et gouvernance
Le fait de faire bouchonner plusieurs applications par plusieurs équipes pour de multiples environnements a posé la question de la gouvernance de ces bouchons : qui est responsable d'un bouchon ? comment éviter les doublons et
les conflits ?
Il a donc été nécessaire de définir des règles d'organisation et de rédaction des mocks :
- Sur l'environnement de DEVELOPPEMENT,
l'équipe consommatrice est responsable de ses mocks
- Hors environnement de DEVELOPPEMENT, une équipe transverse est responsable de tous
les mocks
- L'arborescence des mocks dans le dépôt Git est la suivante :
📁 cible/
└── 📁 environnement/
└── 📁 operation/
└── 📄 fichier JSON
- Les contraintes de nommage suivantes s'appliquent :
- La cible est l'application
que l'on bouchonne- L'environnement est l'environnement sur lequel on bouchonne
(
DEVELOPPEMENT, INTEGRATION, RECETTE PREPROD)- L'`operation` est l'opération bouchonnée (par ex.
ajouterUtilisateur)- Sur l'environnement de DEVELOPPEMENT,
le fichier JSON est nommé selon ce motif : <consommateur>-<texte libre>.json
- <consommateur> est à remplacer par le nom de l'équipe
qui va consommer ce mock -
<texte libre> est un texte libre qui doit décrire le contenu du mock-
Hors environnement de DEVELOPPEMENT, le fichier JSON est nommé selon ce motif : <texte libre>.jsonEn outre, il a été nécessaire de réécrire les URL des mocks sur l'environnement de
DEVELOPPMENT pour faire apparaître
le consommateur et éviter des collisions d'URL.
Ainsi, si les équipes
equipe1 et
equipe2 définissent chacune le mock suivant...
{
"mappings": [
{
"name": "hello world",
"request": {
"url": "/helloworld",
"method": "GET"
},
"response": {
"status": 200,
"jsonBody": {"response": "Hello world"},
"headers": {}
},
"persistent": true,
"priority": 5
}
],
"meta": {
"total": 1
}
}
Elles doivent créer deux fichiers dans l'arborescence du dépôt :
📁 hello/
└── 📁 DEVELOPPEMENT/
└── 📁 helloworld/
├── 📄
equipe1-helloworld.json
└── 📄 equipe2-helloworld.json
Finalement, un pipeline modifie à la volée les champs `url` des mocks afin d'exposer les URL suivantes :
-
http://localhost:8081/equipe1/helloworld-
http://localhost:8081/equipe2/helloworld
Cette approche permet d'accorder à toutes les équipes une complète indépendance et un contrôle total sur la rédaction de leurs bouchons lors des phases de développement, tout en garantissant
une cohérence des jeux de données sur les autres environnements.
Générer des bouchons avec des données fictives
Une des plus-values des bouchons est de fournir des données fausses, mais suffisamment proches de la réalité pour permettre de tester de multiples cas métier ; la génération en masse de bouchons réalistes
est donc un enjeu significatif pour les équipes de test. Si des jeux de données fictives ne peuvent pas être fournis par les applications qui sont bouchonnées, il est possible de faire appel à des composants tiers.
Parmi les composants permettant de générer des données fictives, Faker (https://fakerjs.dev/guide/) est suffisamment polyvalent
et simple d'utilisation pour s'intégrer rapidement dans notre boite à outils. Il permet entre autres de générer des données :
- De personnes : noms, genres, biographies, intitulés de poste...
- De lieux : adresses, codes postaux, noms de rue, états, pays...
- Temporelles : passé, présent, futur, récent, bientôt…
- Financières : informations fictives sur des comptes
ou des transactions
- Commerciales : prix, noms de produits...
Par exemple, en se basant sur un template de stub pour Wiremock comme celui-ci...
{
"mappings": [
{
"name": "{{name}}",
"request": {
"url": "{{url}}",
"method": "{{method}}"
},
"response": {
"status": "{{status}}",
"jsonBody": "{{body}}",
"headers": {}
},
"persistent": true,
"priority": 5
}
],
"meta": {
"total": 1
}
}
Et à l'aide d'un script python :
import json
from faker import Faker
import random
# Initialize the faker
fake = Faker()
""" Replace mustache placeholders """
def replace_mustache_placeholders(obj, values):
if isinstance(obj, dict):
return {k: replace_mustache_placeholders(v, values) for k, v in obj.items()}
elif isinstance(obj, list):
return [replace_mustache_placeholders(item, values) for item in obj]
elif isinstance(obj, str) and obj.startswith('{{') and obj.endswith('}}'):
key = obj[2:-2].strip()
return values.get(key, obj)
else:
return obj
""" Generate result """
def generate_json_from_template(template_file_name, output_file_name, values):
with open(template_file_name, 'r') as f:
template_data = json.load(f)
filled_data = replace_mustache_placeholders(template_data, values)
with open(output_file_name, 'w') as f:
json.dump(filled_data, f, indent=2)
def main_loop():
for i in range(10):
name = fake.name()
# create response object
body = {
"name": name,
"age": random.randint(18, 99),
"address": {
"street": fake.street_address(),
"city": fake.city(),
"zip_code": fake.postcode()
}
}
# placeholders values dictionnary
values = {
'name': f"GET getPerson ({name})",
'url': f"/api/v1/person/{i}",
'method': 'GET',
'status': 200,
'body': body
}
generate_json_from_template('template.json', f"output_{i}.json", values)
if __name__ == '__main__':
main_loop()
Il est possible de générer une quantité illimitée de bouchons retournant une adresse postale complète (nom complet, rue, ville code postal) :
{
"mappings": [
{
"name": "GET getPerson (Brandon Wolf)",
"request": {
"url": "/api/v1/person/5",
"method": "GET"
},
"response": {
"status": 200,
"jsonBody": {
"name": "Brandon Wolf",
"age": 68,
"address": {
"street": "215 Morales Orchard",
"city": "Guzmanville",
"zip_code": "29287"
}
},
"headers": {}
},
"persistent": true,
"priority": 5
}
],
"meta": {
"total": 1
}
}
Il est donc possible de générer des jeux de données importants ou complexes (par exemple impliquant plusieurs bouchons cohérents) en ne nécessitant qu'une charge de travail relativement limitée.
Manipuler les bouchons : URL et métadonnées
Injection de métadonnées
Le schéma des stubs Wiremock autorise l'injection de métadonnées dans les définitions de bouchons. Ces métadonnées
ne sont pas utilisées dans le bouchon en lui-même mais peuvent faciliter la collaboration entre équipes en apportant des informations sur les bouchons qui sont déployés.
Nous avons ainsi été
en mesure d'injecter :
- La version courant des bouchons, issue du tag Git
- La cible (l'application que l'on bouchonne), issue du répertoire dans lequel se trouve le fichier JSON
- L'équipe consommatrice, issue du
nom du fichier dans l'environnement DEVELOPPEMENT
Un script shell et l'outil jq suffisent :
#!/bin/sh
NEW_TAG_VERSION=1.0.1-RC1
# Add version to metadata
for file in $(find . -type f -name "*.json"); do
echo "Adding 'version' to $file"
temp_file=$(mktemp)
cmd=$(printf '.mappings |= map(. + {"metadata": {"version": "%s"}})' $NEW_TAG_VERSION)
jq "$cmd" $file > $temp_file
mv "$temp_file" "$file"
done
# Add target to metadata
for file in $(find . -type f -name "*.json"); do
echo "Adding 'cible' to $file"
CIBLE=$(echo $file | cut -d'/' -f2)
temp_file=$(mktemp)
cmd=$(printf '.mappings[].metadata += {"cible": "%s"}' $CIBLE)
jq "$cmd" $file > $temp_file
mv "$temp_file" "$file"
done
# Add consumer only if environment is 'DEVELOPPEMENT'
for file in $(find . -type f -path "*/DEVELOPPEMENT/*" -name "*.json"); do
echo "Adding 'consumer' to $file"
CONSUMER=$(echo "${file##*/}" | cut -d'-' -f1)
temp_file=$(mktemp)
cmd=$(printf '.mappings[].metadata += {"consommateur": "%s"}' $CONSUMER)
jq "$cmd" $file > $temp_file
mv "$temp_file" "$file"
done
En appliquant le script sur une arborescence plus complète :
📁 hello/
├── 📁 DEVELOPPEMENT/
│ ├── 📁 foobar/
│ │ └── 📄 systemteam-stub1.json
│ └── 📁 helloworld/
│ └── 📄 systemteam-stub2.json
└── 📁 RECETTE/
└── 📁 helloworld/
└── 📄 stub3.json
Nous avons obtenu le résultat ci-dessous :
Adding 'version' to ./hello/DEVELOPPEMENT/foobar/systemteam-stub1.json
Adding 'version' to ./hello/DEVELOPPEMENT/helloworld/systemteam-stub2.json
Adding 'version' to ./hello/RECETTE/helloworld/stub3.json
Adding 'cible' to ./hello/DEVELOPPEMENT/foobar/systemteam-stub1.json
Adding 'cible' to ./hello/DEVELOPPEMENT/helloworld/systemteam-stub2.json
Adding 'cible' to ./hello/RECETTE/helloworld/stub3.json
Adding 'consumer' to ./hello/DEVELOPPEMENT/foobar/systemteam-stub1.json
Adding 'consumer' to ./hello/DEVELOPPEMENT/helloworld/systemteam-stub2.json
Et voici à quoi ressemble un
stub dans lequel les métadonnées ont été injectées :
{
"mappings": [
{
"name": "hello world",
"request": {
"url": "/helloworld",
"method": "GET"
},
"response": {
"status": 200,
"jsonBody": {"response": "Hello world"},
"headers": {}
},
"persistent": true,
"priority": 5,
"metadata": {
"version": "1.0.1-RC1",
"cible": "cible1",
"consommateur": "systemteam"
}
}
],
"meta": {
"total": 1
}
}
Récriture des URL
De la même façon, il a été possible de réécrire les URL avec jq, et ce pour chacune des capacités offertes par
Wiremock (url, urlPath, urlPathPattern et urlPattern)
#!/bin/sh
# Add consumer in URL only if environment is 'DEVELOPPEMENT'
for file in $(find . -type f -path "*/DEVELOPPEMENT/*" -name "*.json"); do
echo "Adding 'consumer' to url in $file"
CONSUMER=$(echo "${file##*/}" | cut -d'-' -f1)
temp_file=$(mktemp)
# Prepend the consumer to the values of urlPathPattern, url, urlPath, and urlPattern only if they exist
cmd=$(printf '.mappings |= map(if .request then (.request |= with_entries(if .key | IN("urlPathPattern", "url", "urlPath", "urlPattern") then .value = "/%s" + .value else . end)) else . end)' $CONSUMER)
jq "$cmd" $file > $temp_file
mv "$temp_file" "$file"
done
Le résultat est le suivant, exposant ainsi le bouchon sur l'URL
http://localhost:8081/systemteam/helloworld :
{
"mappings": [
{
"name": "hello world",
"request": {
"url": "/systemteam/helloworld",
"method": "GET"
},
"response": {
"status": 200,
"jsonBody": {"response": "Hello world"},
"headers": {}
},
"persistent": true,
"priority": 5,
"metadata": {
"version": "1.0.1-RC1",
"cible": "hello",
"consommateur": "hello.json"
}
}
],
"meta": {
"total": 1
}
}
Industrialisation
Afin de fournir aux équipes une solution pérenne et automatisée, nous avons compilé tous ces éléments dans 4 pipelines Jenkins :
- Le Pipeline de build est exécuté automatiquement à chaque fois que le code est poussé sur le server. Il ne fait que vérifier la syntaxe des fichiers JSON avec jq
- Les Pipeline de Release Candidate et Pipeline de Release Finale sont manuels et effectuent les opérations suivantes :
- Vérification de la syntaxe des fichiers JSON
-
Injection les métadonnées
- Réécriture les URL
-
Création une release (= un tag dans Git)
- Le Pipeline de déploiement est manuel et effectue les opérations suivantes :
- Checkout du tag sélectionné
-
Suppression tous les mocks déjà déployés dans l'environnement sélectionné
- Déploiement tous les mocks conçus
pour l'environnement sélectionné (en tenant compte des règles décrites ci-dessus pour l'environnement de DEVELOPPEMENT)
Ces automatisations ont permis aux équipes de travailler avec les mocks comme avec n'importe quelle base de code et de profiter d'une chaine CI/CD complète.
Conclusion
L'industrialisation de notre stratégie de mock a apporté de la valeur à chaque étape du cycle de vie de développement :
- En fournissant une source unique de vérité pour les simulations,
elle a favorisé la collaboration transparente entre les équipes, réduisant ainsi les malentendus et garantissant l'alignement
- En permettant de créer des bouchons fiables et versionnés, elle a accélèré
les cycles de développement et permis aux équipes de travailler de manière efficace et cohérente
- En simplifiant la gestion des cas de test complexes ou dépendants de l'extérieur, elle a apporté
de la stabilité aux environnements manipulés par les équipes de test, réduisant ainsi de façon significative les risques d'erreurs liés aux jeux de données
Enfin, elle nous a permis de
rappeler au client notre savoir-faire en termes d'industrialisation et d'intégration, et de prouver notre engagement à faire de chaque projet un moteur de l'amélioration continue.