De l'importance des tests
Un article de Agile-Swiss.
Dans cet article, je discute des pratiques de tests, qu'ils soient intégrés au code ou non, écrits avant ou après la fonctionnalité testée. Je ne fais que survoler un sujet très vaste et méritant bien plus que ces quelques lignes pour en démontrer tous les avantages. Et oui !, cet article est résolument partisan. Car je le suis à 100%.
Sommaire |
Notions élémentaires
Les tests peuvent être de plusieurs natures, selon ce qu'ils testent ou expriment. Je distingue dans cet article :
- les tests unitaires, qui valident une unité de code telle qu'une fonction ou une méthode, une classe voire un module. Ces tests valident le code écrit par les développeurs.
- les tests fonctionnels, qui valident une fonctionnalité. Ces tests, de plus haut niveau, expriment les spécifications et font le lien entre l'intention du client et la réalisation des développeurs.
- les contrats intégrés au code, qui valident le contexte d'utilisation d'une unité de code.
Perception
L’écriture de tests unitaires ou fonctionnels est trop souvent perçue par les décideurs comme un surcoût — en temps de développement et en argent — pouvant être évité. La concurrence faisant, les tests unitaires et fonctionnels sont rapidement éliminés des plannings pour diminuer la charge visible de travail. Les tests sont ajournés jusqu'à la phase de mise en production. De plus les réalisateurs (développeurs) résistent fortement : écrire un test est rébarbatif, pour ne pas dire pire. Ainsi tout le monde s’accorde pour ignorer cette pratique autant que faire se peut.
Et nous savons tout le résultat : une majorité de projets informatiques échouent à satisfaire tous les objectifs initiaux, en particulier les temps de réalisation ou budget. Je conclus trop vite ; ce n’est bien sûr par la seule raison, mais elle n'est en aucun cas à sous-estimer.
Test Driven Development
"Cette pratique de l'extreme programming voudrait que chaque bout de code écrit par un développeur soit testé. Qui plus est, le test devrait être écrit avant le bout de code à tester ! C'est ridicule. Écrire un test pour un code inexistant n'a aucun sens, et ne compilera même pas."
Faux !
Au lieu de coder directement la fonctionnalité souhaitée, les développeurs vont écrire le test de conformance aux spécifications de la fonctionnalité : les spécifications exprimées par le client en langage humain sont ainsi traduites en langage de programmation. Ceci fait, les développeurs peuvent coder la fonctionnalité de façon à passer le test écrit plus haut. Le test exprime les spécifications — l'intention — que devra satisfaire l'implémentation — la réalisation de l'intention —. L'intention précède la réalisation.
"C'est une perte de temps de transcrire la spécification sous forme de test plutôt que de coder directement la fonctionnalité. Sautons cette étape et gagnons du temps !"
Faux !
On ne gagne jamais de temps. Écrire un test va prendre du temps bien sûr. Mais on peut essayer d'en utiliser globalement moins en l'utilisant mieux. Un test écrit tôt servira beaucoup : à raisonnablement garantir que la fonctionnalité est conforme aux spécifications maintenant et plus tard, que lors de refactorisations les spécifications sont toujours respectées, que le changement d'environnement d'exécution n'impacte pas la fonctionnalité, etc. Les travaux de vérification et de debugging s'en trouvent facilités, les développeurs y consacrent donc moins de temps, et la qualité du code est augmentée. Un code testable est mieux modularisé, et sa réutilisation est grandement facilitée.
"Souvent on ne dispose pas de spécifications que l'on pourrait traduire sous forme de tests, en particulier lors de la conception de l'interface graphique d'une application. Si on écrit des tests, il faudra les écrire plus tard, et comme ils seront écrits plus tard, ils serviront moins. Bref, oublions les tests."
Faux !
Il faut distinguer le travail d'étude de solutions — la conception — du travail de spécification — l'intention — puis de codage — la réalisation —. La réalisation de prototypes au cours de la conception servira à visualiser des solutions possibles, et permettra d'établir des spécifications claires et validées. Ces spécifications seront exprimées sous forme de tests, et les fonctionnalités codées en utilisant des éléments tirés des prototypes.
Tests à posteriori
Il y a des cas où les tests ne peuvent êtres écrits au préalable, par exemple lors de travaux sur une application existante dépourvue de tests dignes de ce nom. Mais là encore, on peut adopter cette pratique de test driven development pour tous les éléments ajoutés, avec les bénéfices qui en découlent.
L'écriture de tests à posteriori validant des fonctionnalités existantes aidera à leur compréhension, facilitera leur refactorisation et augmentera la qualité du code. Il ne s'agit évidemment pas d'écrire des tests pour toutes les fonctionnalités existantes, cela prendrait trop de temps. Une équipe expérimentée saura trouver un juste équilibre.
L'écriture de tests à posteriori est plus complexe et les bénéfices plus difficiles à mesurer… Comme quoi il vaut mieux y penser avant !
Design by Contract
Je ne pouvais pas raisonnablement conclure cet article sans parler de cette pratique. S'il arrive très (trop) souvent que je doive renoncer au test driven development, nombre de mes clients étant plus que frileux devant les méthodologies agiles et leurs pratiques, je ne saurais par contre renoncer au design by contract.
Mon propos n'étant pas d'expliquer en détail de quoi il s'agit, je vous laisse le soin de parcourir le Web à ce sujet, en commençant par exemple par La programmation par contrat, An introduction to Design by Contract (en anglais) et The Lessons of Ariane (encore en anglais). Le langage Eiffel, conçu par Bertrand Meyer, offre un support très complet de cette pratique et nombre de documentations existent à ce sujet.
Pour simplifier à l'extrême, disons que chaque fonction (ou procédure ou méthode) vérifie en entrée la validité des arguments et des données globales, de classe ou d'instance par un ensemble de préconditions, puis vérifie en sortie la validité des données retournées et des modifications des données globales, de classe ou d'instance par un ensemble de postconditions. Chaque classe ou module est en outre doté d'un invariant, un ensemble de conditions définissant un état valide.
Il s'agit somme toute de tests intégrés dans le code. Les tests à priori expriment les spécifications, mais ne peuvent (ni ne doivent) prévoir et valider toutes les utilisations possibles d'une fonctionnalité. Les contrats intégrés au code permettent de vérifier que l'utilisation qui est faite d'une fonctionnalité est valide dans tous les cas.
Cette pratique facilite la réutilisation du code, augmente la robustesse, aide à la localisation des bugs, bref, augmente la qualité du code. A l'image des tests, on a tout à gagner à écrire ces contrats avant la fonctionnalité. On exprime et fixe le contexte d'utilisation valide, puis on code la fonctionnalité l'esprit libéré.
Retour d'expérience
J'ai eu la chance en 2004 de travailler sur un projet quasi-idéal : il s'agissait de développer un logiciel de suivi d'appels et d'interventions pour le Help Desk d'une banque à Genève, en ayant pratiquement carte blanche.
L'équipe Help Desk était constituée de cinq personnes, utilisant autant de méthodes de suivi. "L'ancien" utilisait une base bricolée par ses soins sous Access, en perpétuel chantier depuis des années, et un collaborateur une version antérieure plus ou moins stabilisée. Une personne utilisait une feuille Excel, une autre un classeur de fiches préimprimées et la dernière des post-its collés partout.
Le responsable informatique souhaitait améliorer l'efficacité de l'équipe support ainsi que la visibilité du suivi. Il préférait un développement spécifique pensé au plus proche des habitudes et besoins avérés de l'équipe plutôt qu'une application de suivi commerciale telle que Remedy. Il jugeait en effet difficile et peu souhaitable d'imposer une telle application sans fortement contrarier son équipe Help Desk. La méthode douce.
J'avais donc comme point de départ un budget déterminé, un cahier des charges limité listant quelques fonctionnalités de visualisation des incidents ouverts ou traités, et carte blanche pour le reste. J'avais donc le choix des méthodologies, des outils et plateformes. J'ai résolument adopté une approche agile, MacOS X comme plateforme de développement et Linux pour le déploiement, WebObjects, MySQL et bien sûr Ant, JUnit ou encore FogBugz…
Pour en venir au sujet de cet article, j'ai pu mettre en œuvre le test driven development dès les premières lignes de code, de même que le design by contract que j'affectionne tellement. Quelques semaines à peine après le début du développement, j'ai pu mettre en production une première version de l'application, et inciter les collaborateurs du Help Desk à l'adopter progressivement. Une nouvelle version était déployée chaque semaine en moyenne, incorporant les fonctionnalités fraîchement décidées avec l'équipe.
Voici les principaux bénéfices que j'ai observés, découlants directement de ces pratiques :
- La confiance apportée par l'ensemble des tests unitaires, fonctionnels et des contrats m'a permis des mises en production rapides et rapprochées, accélérant d'autant l'adoption du logiciel par l'équipe Help Desk ainsi que les commentaires en retour.
- La revue de code continue et les refactorisations successives, grandement facilitées par le filet des tests, ont abouti à un code très propre, à la fois simple et lisible.
- De plus, les tests ont mis en évidence beaucoup de bugs avant déploiement et grandement facilité la recherche (et la correction) des bugs rapportés par l'équipe support.
J'estime avoir consacré environ moitié moins de temps à l'écriture des tests et contrats, au debugging et à la revue de code qu'au seul debugging si j'avais renoncé au test driven development et au design by contract. Il s'agit d'une estimation empirique bien sûr, mais assez réaliste je pense.
Le coût final du projet étant inférieur au budget prévu, une somme suffisante restait pour financer un contrat de maintenance de l'application.
Cela fait maintenant quelques mois que le développement est achevé. Les collaborateurs du Help Desk utilisent cette application continuellement et n'ont rapporté qu'un disfonctionnement génant, qui fut vite localisé et corrigé. La qualité du suivi a fait un grand bond en avant, le responsable informatique a une meilleure vision du travail effectué tout en perdant moins de temps en réunions hebdomadaires avec son équipe. Quelques personnes de la banque utilisent même la base de connaissances intégrée avant de téléphoner au Help Desk.
Je suis convaincu que la qualité de cette application aurait été bien moindre et le budget alloué probablement insuffisant si l'approche avait été différente. Et si j'avais encore eu besoin d'être convaincu de l'intérêt et de l'efficacité de ces pratiques, je le serais aujourd'hui !
Conclusion
L'adoption systématique des test driven development et design by contract a des effets bénéfiques immédiats sur la qualité du code :
- Robustesse augmentée
- Plus grande confiance accordée à l'ensemble du code (y compris écrit par d'autres)
- Les spécifications sont exprimées en langage de programmation
Tout au long du développmement, bien d'autres bénéfices seront notables :
- Beaucoup moins de temps est consacré au debugging
- Le stress des développeurs est nettement diminué
- Les refactorisations sont grandement simplifiées (combien de fois avez-vous pensé : « Il faudrait changer ceci ou cela, mais c'est trop tard maintenant car je risque de tout casser... » ?)
- L'exécution de l'ensemble des tests plusieurs dizaines de fois chaque heure limite considérablement le risque d'une modification désastreuse (et donc d'y passer la nuit)
Et au final :
- La productivité est augmentée
- La planification est mieux prévisible
- Le code est de meilleure qualité : plus robuste, plus lisible, plus simple, moins buggé
Et, croyez-le ou non, le travail des développeurs n'en sera que plus agréable, le travail du project manager également !
Références
Test Driven Development
Design by Contract
