The DevOps Handbook
Chapitre 8 / 14 · 19 min de lecture

Des tests automatisés rapides et fiables

Construire une suite de tests automatisée rapide qui valide en continu l'état déployable du système, et arrêter la chaîne dès qu'un build casse.

À ce stade, le développement et l'assurance qualité travaillent dans des environnements proches de la production, et chaque fonctionnalité acceptée est intégrée et exécutée dans un tel environnement, avec tous les changements versionnés. Mais nous risquons des résultats désastreux si nous ne trouvons et ne corrigeons les erreurs que dans une phase de test séparée, conduite par un département QA distinct après la fin du développement. Quand le test n'a lieu que quelques fois par an, le développeur apprend ses erreurs des mois après les avoir introduites : le lien entre cause et effet s'est estompé, la résolution tient de l'archéologie et de la lutte d'incendie, et — pire encore — notre capacité à apprendre de l'erreur s'évapore. Ce chapitre montre comment bâtir l'antidote : une suite de tests automatisée rapide et fiable, intégrée à chaque commit, doublée d'une culture qui arrête la chaîne dès qu'un build casse.

L'enjeu économique est limpide. Comme l'observe Gary Gruver, « sans tests automatisés, plus nous écrivons de code, plus il faut de temps et d'argent pour le tester — dans la plupart des cas, c'est un modèle économique totalement non viable pour toute organisation technologique ». Le test manuel ne passe pas à l'échelle ; seul l'automatisation rend soutenable la croissance du code.

Google et le Google Web Server : de la peur à la confiance

Google incarne aujourd'hui une culture du test automatisé à grande échelle, mais il n'en a pas toujours été ainsi. En 2005, quand Mike Bland rejoint l'organisation, déployer sur Google.com est souvent extrêmement problématique, en particulier pour l'équipe du Google Web Server (GWS) — l'application C++ qui traite toutes les requêtes de la page d'accueil et de nombreuses autres pages.

Bland raconte qu'au milieu des années 2000, modifier ce serveur était devenu si difficile que l'équipe GWS faisait office de dépotoir : toutes les équipes y déversaient leurs fonctionnalités de recherche, chacune développant son code indépendamment des autres. Les builds et les tests prenaient un temps fou, du code partait en production sans avoir été testé, et les équipes intégraient de gros changements rares qui entraient en conflit les uns avec les autres. Les conséquences étaient lourdes : des résultats de recherche erronés ou ralentis affectant des milliers de requêtes, donc une perte de revenu et de confiance des clients.

Note

Bland résume le climat d'une phrase devenue célèbre : « La peur est devenue le tueur de l'esprit. La peur empêchait les nouveaux d'apporter des changements parce qu'ils ne comprenaient pas le système. Mais la peur empêchait aussi les vétérans de changer quoi que ce soit — parce qu'ils ne le comprenaient que trop bien. » C'est exactement le syndrome qu'un bon harnais de tests dissout : il rend le changement sûr, donc possible.

Le responsable de l'équipe, Bharat Mediratta, parie que les tests automatisés résoudront le problème. La règle posée est tranchée : aucun changement ne sera accepté dans GWS sans tests automatisés l'accompagnant. L'équipe met en place un build continu qu'elle maintient religieusement au vert, surveille la couverture de test et veille à ce qu'elle augmente avec le temps, rédige des guides et impose à tous les contributeurs — internes comme externes — de les suivre.

Les résultats sont saisissants. GWS devient rapidement l'une des équipes les plus productives de l'entreprise, intégrant chaque semaine de nombreux changements venus d'équipes différentes tout en maintenant un rythme de livraison rapide. Les nouveaux arrivants contribuent vite à ce système complexe, grâce à la bonne couverture de test et à la santé du code. De là naît le Testing Grouplet, un groupe informe d'ingénieurs résolus à diffuser cette culture dans toute l'organisation : sur cinq ans, par des programmes de formation, la newsletter « Testing on the Toilet » affichée jusque dans les toilettes, la feuille de route « Test Certified » et des journées « fix-it », ils répliquent la culture du test automatisé à travers tout Google.

Google en 2013Volume quotidien
Commits de code40 000 / jour
Builds50 000 / jour (jusqu'à 90 000 en semaine)
Suites de tests automatisées120 000
Cas de test exécutés75 millions / jour
Ingénieurs sur l'outillage de productivité100+ (0,5 % de la R&D)

Aujourd'hui, quand un développeur Google commet du code, celui-ci est automatiquement exécuté contre des centaines de milliers de tests automatisés ; s'il passe, il est fusionné dans le tronc commun, prêt à partir en production. Ce qui rend le système viable, souligne Eran Messeri (groupe Developer Infrastructure), n'est pas une politique rigide mais une culture de haute confiance et un respect mutuel : « il n'y a pas de règle dure du type "si tu casses la production de plus de dix projets, tu as un SLA de dix minutes pour réparer". Il y a un accord implicite que chacun fait le nécessaire pour garder le pipeline en marche. On sait tous qu'un jour je casserai ton projet par accident ; le lendemain, tu casseras peut-être le mien. »

Construire, tester et intégrer en continu : le pipeline de déploiement

Pour répliquer ces résultats, le mécanisme central est le pipeline de déploiement. Défini pour la première fois par Jez Humble et David Farley dans leur ouvrage Continuous Delivery: Reliable Software Releases Through Build, Test, and Deployment Automation, il garantit que tout code versionné est automatiquement construit et testé dans un environnement proche de la production. On découvre ainsi toute erreur de build, de test ou d'intégration dès qu'un changement est introduit, ce qui permet de la corriger immédiatement et de rester en permanence dans un état déployable et livrable.

Pour y parvenir, les processus automatisés de build et de test doivent tourner dans des environnements dédiés, et cela pour plusieurs raisons :

  • le processus de build et de test tourne en permanence, indépendamment des habitudes de travail de chaque ingénieur ;
  • un processus séparé force à comprendre toutes les dépendances nécessaires pour construire, packager, exécuter et tester le code — ce qui élimine le fameux « ça marchait sur le portable du développeur, mais c'est cassé en production » ;
  • on peut packager l'application pour rendre répétable l'installation du code et des configurations dans un environnement (par exemple RPM, yum, npm sous Linux, OneGet sous Windows, ou des systèmes spécifiques au framework comme les fichiers EAR et WAR pour Java, les gems pour Ruby) ; au lieu de paquets, on peut aussi choisir de packager l'application dans des conteneurs déployables (Docker, Rkt, LXD, AMI) ;
  • les environnements peuvent être rendus plus proches de la production de façon cohérente et reproductible (compilateurs retirés, drapeaux de débogage désactivés, etc.).

Le pipeline commence par le commit stage (étage de validation), qui construit et package le logiciel, exécute les tests unitaires automatisés et effectue des validations complémentaires — analyse statique, détection de duplication, analyse de couverture, vérification du style. S'il réussit, il déclenche l'acceptance stage (étage d'acceptation), qui déploie automatiquement les paquets produits par le commit stage dans un environnement proche de la production et y lance les tests d'acceptation automatisés.

À retenir

Une fois les changements acceptés en gestion de version, on veut packager le code une seule fois, de sorte que les mêmes paquets servent à déployer le code tout au long du pipeline. Le code est ainsi déployé dans les environnements de test intégré et de pré-production exactement comme il le sera en production, ce qui réduit les écarts qui causent des erreurs difficiles à diagnostiquer en aval (compilateurs, drapeaux de compilation, versions de bibliothèque ou configurations différents). Les conteneurs comme Docker servent eux aussi de mécanisme de packaging : créés une fois lors du build, ils offrent le « write once, run anywhere » et imposent la cohérence de tous les artefacts.

De nombreux outils fournissent cette fonctionnalité de pipeline, souvent open source : Jenkins, GoCD (ThoughtWorks Go), Concourse, Bamboo, Microsoft Team Foundation Server, TeamCity, GitLab CI, ainsi que des solutions cloud comme Travis CI et Snap. L'infrastructure du pipeline devient alors aussi fondamentale pour nos processus de développement que la gestion de version : elle conserve l'historique de chaque build (quels tests, sur quel build, déployé dans quel environnement, avec quels résultats), ce qui permet de déterminer vite ce qui a cassé le pipeline et, le plus souvent, comment le réparer — tout en générant automatiquement les preuves utiles à l'audit et à la conformité.

Bâtir une suite de validation rapide et fiable

Pour comprendre pourquoi l'intégration et le test doivent être continus, imaginons une équipe de dix développeurs qui versionnent leur code chaque jour, et un build nocturne. Si quelqu'un casse ce build nocturne, l'équipe ne le découvre que le lendemain : il faudra des minutes — plus probablement des heures — pour identifier quel changement a causé le problème, qui l'a introduit et comment le réparer. Pire, si le problème vient non d'un changement de code mais d'une configuration d'environnement, l'équipe croira l'avoir résolu parce que les tests unitaires passent, pour découvrir qu'ils échouent encore la nuit suivante. Et dix nouveaux changements auront entre-temps été versionnés, chacun susceptible d'introduire d'autres défauts.

À retenir

Le feedback lent et périodique tue — surtout pour les grandes équipes. Quand les builds et les tests sont sans cesse cassés, les développeurs cessent même de versionner leurs changements (« à quoi bon, puisque tout est toujours rouge ? ») et attendent la fin du projet pour intégrer. On retombe alors dans les gros lots, les intégrations « big-bang » et les déploiements douloureux — précisément ce que l'intégration continue était censée éliminer.

La parade : des tests automatisés rapides qui s'exécutent dans nos environnements de build et de test dès qu'un changement entre en gestion de version. En général, ces tests se répartissent en trois catégories, de la plus rapide à la plus lente.

CatégorieCe qu'elle valideCaractéristique
Tests unitaires (unit tests)Une méthode, une classe ou une fonction isoléeRapides, sans état ; on « bouchonne » (stub) bases de données et dépendances externes
Tests d'acceptation (acceptance tests)L'application dans son ensemble : critères métier d'une user story, exactitude d'une API, absence de régressionPlus lents ; exécutés après les tests unitaires
Tests d'intégration (integration tests)L'interaction correcte avec les vraies applications et services de production (plus de stubs)Fragiles (brittle) ; à minimiser

Humble et Farley distinguent ainsi les deux premières familles : « Le but d'un test unitaire est de montrer qu'une seule partie de l'application fait ce que le programmeur entend. L'objectif des tests d'acceptation est de prouver que l'application fait ce que le client voulait — pas qu'elle fonctionne comme ses programmeurs le pensent. » Les tests d'intégration étant souvent fragiles, on cherche à en avoir le moins possible et à attraper le maximum de défauts plus tôt, en unitaire et en acceptation ; la capacité à utiliser des versions virtuelles ou simulées des services distants devient une exigence d'architecture.

Sous la pression des délais, les développeurs cessent parfois d'écrire des tests unitaires, quelle que soit la définition de « terminé ». Pour le détecter, on peut mesurer et rendre visible la couverture de test (test coverage), et même faire échouer la suite quand elle tombe sous un seuil (par exemple moins de 80 % des classes couvertes) — mais seulement quand l'équipe valorise déjà le test automatisé, car cette métrique se truque trop facilement. Martin Fowler rappelle pour sa part qu'« un build [et un processus de test] de dix minutes est parfaitement raisonnable » : on compile et on lance d'abord les tests unitaires localisés, base de données entièrement bouchonnée ; un second étage exécute ensuite les tests d'acceptation qui touchent la vraie base et le comportement de bout en bout, et peut prendre quelques heures.

La pyramide des tests : attraper l'erreur au plus tôt

Un objectif de conception explicite de la suite : trouver les erreurs le plus tôt possible. D'où l'ordre d'exécution — les tests rapides (unitaires) avant les lents (acceptation, intégration), eux-mêmes avant tout test manuel. Corollaire : chaque erreur doit être attrapée par la catégorie de test la plus rapide possible. Si l'essentiel de nos erreurs remonte des tests d'acceptation et d'intégration, le feedback aux développeurs est d'un ordre de grandeur plus lent qu'avec des tests unitaires — et l'intégration mobilise des environnements rares et complexes, utilisables par une seule équipe à la fois, ce qui retarde encore le retour.

Astuce

Chaque fois qu'une erreur est trouvée par un test d'acceptation ou d'intégration, écrivez un test unitaire qui l'aurait attrapée plus tôt, plus vite et moins cher. C'est ce mouvement, répété, qui construit la bonne pyramide : on déplace la détection vers la gauche (shift left), là où reproduire et corriger un défaut coûte une fraction du prix.

Martin Fowler a formalisé la « pyramide de test idéale », où la majorité des erreurs sont attrapées par les tests unitaires. Trop de programmes de test font l'inverse — l'anti-pattern du cornet de glace (ice-cream cone) inversé, où l'essentiel de l'investissement va au test manuel et à l'intégration.

   PYRAMIDE IDÉALE              ANTI-PATTERN « CORNET DE GLACE »
   (rapide, fiable)             (lent, fragile)

        /  IHM / manuel              ____________
       /    (peu)                      manuel  /   ← le plus gros
      /----                           --------/      (lent, coûteux)
     / accep   service / accep          accep/
    /----------  (un peu)                --/
   /  unitaires   (beaucoup,        ______/______
  /--------------  rapides)         | unitaires |   ← le plus petit
                                     |  (rares)  |

Si les tests unitaires ou d'acceptation deviennent trop difficiles ou coûteux à écrire et maintenir, c'est probablement le signe d'une architecture trop couplée : les frontières entre modules ont disparu (ou n'ont jamais existé). Il faut alors créer un système plus faiblement couplé, où les modules se testent indépendamment sans environnement d'intégration. Même pour les applications les plus complexes, des suites d'acceptation qui s'exécutent en quelques minutes sont possibles.

Rester rapide : exécuter les tests en parallèle

Pour que les tests restent rapides, on les conçoit pour s'exécuter en parallèle, potentiellement sur de nombreux serveurs, et on parallélise aussi des catégories entre elles : quand un build passe les tests d'acceptation, on peut lancer les tests de performance en même temps que les tests de sécurité. On rend tout build ayant passé l'ensemble des tests automatisés disponible pour les tests exploratoires et autres tests manuels ou gourmands en ressources, aussi souvent que possible.

Surtout, tout testeur — ce qui inclut tous nos développeurs — utilise le dernier build ayant passé tous les tests automatisés, au lieu d'attendre qu'un développeur marque un build « prêt à tester ». Ainsi le test arrive au plus tôt dans le processus.

Écrire les tests avant le code : TDD et ATDD

L'une des façons les plus efficaces de garantir une automatisation fiable est d'écrire les tests dans le cadre du travail quotidien, via le développement piloté par les tests (test-driven development, TDD) et sa variante pilotée par les tests d'acceptation (acceptance test-driven development, ATDD). Toute modification commence par l'écriture d'un test automatisé qui valide le comportement attendu et échoue d'abord, puis on écrit le code qui le fait passer. Mis au point par Kent Beck à la fin des années 1990 dans le cadre de l'Extreme Programming, le cycle tient en trois pas :

1. RED    → Écris un test pour le prochain bout de fonctionnalité.
            Vérifie qu'il ÉCHOUE.            → commit
2. GREEN  → Écris juste le code qui fait PASSER le test.  → commit
3. REFACTOR → Restructure code neuf et ancien.
              Vérifie que les tests passent toujours.     → commit

Versionnées aux côtés du code, ces suites forment une spécification vivante et à jour du système : un développeur qui veut comprendre comment utiliser une API y trouve des exemples fonctionnels. Une étude de Nagappan, Maximilien et Williams (Microsoft Research, IBM Almaden, North Carolina State University) a montré que les équipes en TDD produisaient un code 60 à 90 % meilleur en densité de défauts que les équipes non-TDD, pour seulement 15 à 35 % de temps en plus.

Automatiser sans céder aux tests instables

L'objectif est de trouver le maximum d'erreurs via les suites automatisées, pour réduire la dépendance au test manuel. Comme le dit Elisabeth Hendrickson, « le test peut être automatisé, mais créer la qualité ne peut pas l'être. Faire exécuter par des humains des tests qui devraient être automatisés est un gaspillage du potentiel humain. » Une fois le répétitif automatisé, les testeurs — développeurs compris — se consacrent aux activités à haute valeur que la machine ne sait pas faire : test exploratoire, amélioration du processus de test lui-même.

Mais automatiser aveuglément tous les tests manuels produit des effets pervers. On ne veut pas de tests peu fiables qui génèrent des faux positifs (flaky tests) : des tests qui auraient dû passer (le code est fonctionnellement correct) mais échouent à cause d'une lenteur provoquant un timeout, d'un état de départ non maîtrisé, ou d'un état imprévu dû à des stubs ou à des environnements partagés.

Attention

Les tests instables sont un poison lent. Ils font perdre un temps précieux (le développeur relance le test pour savoir s'il y a vraiment un problème), alourdissent l'interprétation des résultats, et finissent par pousser des développeurs stressés à ignorer les résultats de test, voire à désactiver les tests pour se concentrer sur le code. Le résultat est toujours le même : les problèmes sont détectés plus tard, plus durs à corriger, et le stress se propage dans toute la chaîne de valeur.

La règle de bon sens : un petit nombre de tests fiables vaut presque toujours mieux qu'un grand nombre de tests manuels ou de tests automatisés peu fiables. On automatise donc seulement les tests qui valident réellement les objectifs métier visés. Si abandonner un test provoque des défauts en production, on le réintègre — au test manuel d'abord, avec l'ambition de l'automatiser ensuite. Gary Gruver, alors VP qualité chez Macys.com, en témoigne : « Pour un grand site e-commerce, nous sommes passés de 1 300 tests manuels exécutés tous les dix jours à seulement dix tests automatisés à chaque commit — il vaut bien mieux exécuter quelques tests auxquels on fait confiance que des tests peu fiables. Avec le temps, nous avons fait grandir cette suite jusqu'à des centaines de milliers de tests automatisés. » On commence petit et fiable, puis on enrichit.

Performance et exigences non fonctionnelles

Trop souvent, les problèmes de performance ne se révèlent qu'en intégration ou, pire, en production — quand les choses ralentissent insidieusement (une requête sans index dont le temps croît de façon non linéaire) jusqu'à ce qu'il soit trop tard. L'objectif est donc d'écrire et d'exécuter des tests de performance automatisés sur toute la pile (code, base, stockage, réseau, virtualisation) dans le pipeline, pour détecter tôt — quand le correctif est le moins coûteux — qu'un changement a, par exemple, décuplé le nombre d'appels à la base. Quand les tests d'acceptation s'exécutent en parallèle, ils servent de socle : sur un site e-commerce, on lancera des milliers de tests « recherche » en parallèle de milliers de tests « paiement ». Comme l'environnement de performance peut être plus complexe que la production elle-même, on le bâtit tôt et correctement, on journalise chaque exécution et on la compare aux précédentes — quitte à faire échouer la suite si la performance dévie de plus de 2 % d'un run à l'autre.

Le même principe vaut pour les exigences non fonctionnelles (non-functional requirements) : disponibilité, scalabilité, capacité, sécurité. Beaucoup dépendent de la configuration correcte des environnements ; avec des outils d'infrastructure-as-code (Puppet, Chef, Ansible…), on réutilise nos frameworks de test pour valider que les environnements sont construits et configurés correctement, et l'on exécute des analyses statiques sur le code d'infrastructure (Foodcritic pour Chef, puppet-lint pour Puppet) ainsi que des contrôles de durcissement sécurité (server-spec).

Tirer le cordon Andon quand le pipeline casse

Un build vert nous donne une haute confiance que code et environnement fonctionneront comme prévu en production. Pour le garder vert, on crée un cordon Andon virtuel (Andon cord), à l'image de celui du système de production Toyota : dès que quelqu'un introduit un changement qui casse le build ou les tests automatisés, aucun nouveau travail n'est autorisé à entrer dans le système tant que le problème n'est pas résolu (stop the line). Et si quelqu'un a besoin d'aide pour réparer, il mobilise l'aide nécessaire — comme dans l'exemple Google d'ouverture.

   AVANT (sans Andon)                APRÈS (Andon tiré)

   commit casse le build            commit casse le build
        │                                 │
   on empile d'autres commits ──►   STOP : plus aucun commit accepté
        │                                 │
   la base pourrit, les tests       toute l'équipe converge,
   deviennent ininterprétables      corrige ou roll-back IMMÉDIAT
        │                                 │
   « phase de stabilisation »       retour au vert en minutes,
   de plusieurs semaines            petits lots préservés

Au minimum, on notifie toute l'équipe de l'échec pour que n'importe qui corrige ou annule le commit ; on peut même configurer la gestion de version pour bloquer tout nouveau commit tant que le premier étage (build + tests unitaires) n'est pas revenu au vert. Si l'échec venait d'un faux positif, le test fautif est réécrit ou supprimé. Chaque membre de l'équipe est habilité à faire un roll-back pour revenir au vert. Randy Shoup, ancien directeur d'ingénierie de Google App Engine, en donne le sens : « Nous priorisons les objectifs d'équipe sur les objectifs individuels — aider quelqu'un à faire avancer son travail, c'est aider toute l'équipe. […] Tout le monde savait que notre métier n'était pas seulement "écrire du code", mais "faire tourner un service". »

Quand ce sont des étages plus tardifs qui échouent (acceptation, performance), plutôt que d'arrêter tout nouveau travail, on dispose de développeurs et de testeurs d'astreinte chargés de corriger immédiatement — et de créer un test à un étage plus précoce pour attraper toute régression future (un défaut d'acceptation devient un test unitaire ; un défaut d'exploratoire devient un test unitaire ou d'acceptation). Pour rendre les échecs visibles, beaucoup d'équipes installent des indicateurs très voyants : lampes de build au mur, lampes à lave, klaxons, feux tricolores. Cette étape est plus délicate que la construction des serveurs de build : elle ne relève plus de la pure technique mais du changement des comportements et des incitations.

Piège courant

Ne pas tirer le cordon enclenche un cercle vicieux. Quelqu'un casse le build et personne ne corrige ; un autre commit s'empile sur la base cassée, et plus personne ne voit les tests rouges ; comme les tests ne tournent plus de façon fiable, plus personne n'en écrit (« à quoi bon ? »). Nos déploiements redeviennent aussi imprévisibles qu'en waterfall, et l'on retombe dans une « phase de stabilisation » de plusieurs semaines — l'anti-pattern water-Scrum-fall — où l'équipe entière, en crise, prend des raccourcis sous la pression et accumule de la dette technique.

À retenir

  • Sans tests automatisés, plus on écrit de code, plus le tester coûte cher : c'est un modèle non viable. Le pipeline de déploiement valide après chaque changement que le code s'intègre dans un environnement proche de la production, garantissant un état toujours déployable.
  • L'histoire du Google Web Server (Mike Bland, Bharat Mediratta) montre la bascule de la peur — « la peur est le tueur de l'esprit » — vers une culture où aucun changement n'entre sans tests, où le build reste religieusement vert, et qui s'est répliquée à tout Google via le Testing Grouplet.
  • Visez la pyramide de test idéale — beaucoup de tests unitaires rapides, moins de tests de service/acceptation, peu de tests d'IHM — et fuyez l'anti-pattern du cornet de glace inversé, dominé par le test manuel et l'intégration fragile.
  • Attrapez chaque erreur avec la catégorie de test la plus rapide possible (shift left) ; exécutez les tests en parallèle pour tenir l'objectif des dix minutes, et faites tester chacun sur le dernier build vert.
  • Écrivez les tests avant le code (TDD/ATDD, cycle rouge-vert-refactor) : la suite devient une spécification vivante, et les données montrent une densité de défauts 60 à 90 % moindre.
  • Méfiez-vous des tests instables (flaky) qui érodent la confiance : un petit nombre de tests fiables vaut mieux qu'une multitude de tests peu fiables — Gruver est passé de 1 300 tests manuels à 10 tests automatisés sûrs, avant d'en faire grandir des centaines de milliers.
  • Tirez le cordon Andon : quand le build casse, toute l'équipe s'arrête (stop the line) et corrige ou annule immédiatement, plutôt que d'empiler du travail sur une base cassée et de retomber dans une « phase de stabilisation » sans fin.