Défense et illustration des tests isolés - #1
"There is hardly anything in the world that someone cannot make a little worse and sell a little cheaper, and the people who consider price alone are that person’s lawful prey. It’s unwise to pay too much, but it’s worse to pay too little. When you pay too much, you lose a little money — that is all. When you pay too little, you sometimes lose everything, because the thing you bought was incapable of doing the thing it was bought to do. The common law of business balance prohibits paying a little and getting a lot — it can’t be done. If you deal with the lowest bidder, it is well to add something for the risk you run, and if you do that you will have enough to pay for something better." John Ruskin
Lorsque l'on travaille sur une solution logicielle existante, on a régulièrement besoin de changer le code de cette solution. Faut-il écrire des tests lorsqu'on le fait ? Et faut-il plutôt écrire des tests isolés ou bien des tests intégrés ?
Aussi étrange que cela puisse paraître, cette question n'est pas une question technique. Elle relève du management de produit.
Tout code est, ou deviendra assez tôt, du code legacy. Ce qui signifie que tout logiciel en production subit un désalignement entre les objectifs, le contexte et les méthodes initialement définis en vue de produire la solution qui est en place aujourd'hui.
Cela peut venir du fait que le budget initial était insuffisant. Est-ce que la documentation du système est à jour ? Y a t'il suffisamment de cas de tests, dûment documentés ? Vous le voyez bien : budget initial insuffisant.
Cela peut venir du fait que la base de code a grandi au delà du contexte et des objectifs initiaux du projet d'une façon telle que c'était toujours en vue d'ajouter des fonctionnalités et jamais en vue d'adapter l'architecture ou d'améliorer la modularité du code.
Cela peut venir du fait que le prestataire initial et son équipe ont signé pour un projet qui était bien au delà de leur compétences techniques, et que ce fait s'est révélé trop tard pour changer la marche de manœuvre. Que ceux qui n'ont jamais menti sur un CV jettent la première pierre.
Cela peut venir de la dette technique, un terme généralement utilisé pour décrire une combinaison des problèmes mentionnés ci-dessus.
Quelle est la première chose à faire lorsque l'on doit changer du code legacy ?
Dans la plupart des cas : assurez vous que vous disposez de tests que vous pourrez exécuter avant et après les changements que vous allez effectuer sur le code.
La valeur primaire du code réside dans le fait qu'il soit correct. Ou comme le disait Jerry Weinberg :
"Si le code n'a pas besoin de fonctionner, il peut satisfaire tous les autres critères de qualité."
Ne vous jetez pas d'une solution legacy vers une solution cassée.
Dans la majorité des cas, un changement robuste du code coûte moins cher qu'un changement peu sûr. C'est assez contre-intuitif, alors prenons un exemple.
Robuste | Fragile | |
---|---|---|
Coût de changement du code | 0.1 | 0.1 |
Coût d'écriture des tests | 0.5 | 0.0 |
Probabilité de régressions | 0.01 | 0.9 |
Coût de correction des régressions | 1.0 | 1.0 |
Risque associé aux régressions | 0.01 | 0.9 |
Total | 0.61 | 1.9 |
Dans le tableau ci-dessus, nous voyons que les risques de régressions ont été pris en compte dans le coût de l'activité de l'équipe. La probabilité à 0.01 d'une régression coûtant 1 signifie que pour 100 changements effectués sur le code, vous encourez un risque de régression de 1. Une probabilité de 0.9 signifie que pour le même nombre de changements vous encourez un risque d'au moins 90. Au moins, car certaines des actions de correction impliqueront des changements importants sur le code, changements qui à leur tour entraîneront des régressions.
C'est pour cela que l'on écrit des tests : non pas parce que nous ne savons pas coder, mais parce que nous sommes des êtres humains ordinaires et non des "Ninjas" ou des "cowboys", et que nous comprenons les risques, même confusément. Dans ce contexte l'écriture de test autovérifiants constitue une stratégie de prévention des défauts.
Néanmoins, écrire des tests prend du temps. Comment pouvons-nous réduire le coût d'écriture des tests ?
De deux façons possibles : de façon illusoire, ou de façon courageuse.
La façon illusoire de réduire le coût des tests :
[Ecrivez des tests intégrés à la place des tests isolés.]
Au lieu d'écrire des tests qui vérifient chacun des comportements spécifiques de chaque composant du système, vous écrivez des tests portant sur le comportement du système pris comme un tout.
(NB : la démarche [Ecrivez des tests intégrés] n'est pas une stratégie illusoire. En vérité c'est une stratégie de prévention des défauts qui complète les tests isolés en aidant à détecter les défauts issus de problèmes d'intégration.)
Projet | Modèle | Contrôleur | Vue | Persistance | Intégration | Total |
---|---|---|---|---|---|---|
Courageux | 150 | 50 | 50 | 100 | 30 | 330 |
Illusoire | 0 | 0 | 0 | 0 | 100 | 100 |
Dans le tableau ci-dessus on a récapitulé le nombre de tests qu'une équipe de développement a écrit sur un (petit) logiciel. Dans le projet "courageux", la stratégie était d'écrire un cas de test pour chaque comportement distinct de chaque composant du système (ces composants sont regroupés par "couches" : modèle, contrôleurs, vue, persistance). Dans le projet "illusoire", la stratégie était de créer une suite (assez grande et impressionnante) de tests intégrés.
Pourquoi la démarche est-elle qualifiée d'illusoire ? Parce que dans un tel plan, les attentes initiales à propos de la couverture totale des tests sont subrepticement (et parfois subconsciemment) réduites.
Si la somme de tous les comportements distincts de chaque composant s'élève à 300, vérifier ces comportements via des tests intégrés devrait nécessiter autant de cas de test, moins les cas de tests dans lesquels deux ou plusieurs comportements spécifiques ne peuvent se produire sur le même chemin d'exécution. Dans cette situation, où l'on s'appuie uniquement sur des tests intégrés, le nombre de cas de test va subir une explosion combinatoire.
Les tests intégrés sont plus difficiles à écrire et à maintenir que les tests isolés; ils sont également plus lents à exécuter et ils demandent la préparation de jeux de données plus volumineux et plus complexes. Face à ce surcoût, la réaction humaine habituelle sera de réduire les attentes en termes de couverture des tests. Cet ajustement, opéré sans même le mentionner, ou bien alors en utilisant des raisonnements subtils, sera d'autant plus naturel que les tests n'étaient pas réellement planifiés en début de projet. On va tout simplement produire moins de tests qu'il n'en faudrait « dans l’idéal ».
En considérant l'ensemble de votre carrière, au début de combien de projets avez-vous eu à estimer même grossièrement le nombre de cas de test que le système comporterait ? Très peu, j'imagine. Dans combien de projets vous a t'on demandé de produire une estimation de charges détaillée ? Je parierais pour : tous.
Il est plus facile de réduire une attente qui n'a jamais été clarifiée, ni même établie, que d'annoncer un retard officiel accompagné d'une demande de rallonge de budget.
Prenons maintenant le chemin de la méthode courageuse pour réduire le coût des tests :
[Ecrivez des tests isolés pour les parties du comportement qui sont conceptuellement isolées.]
N'est-il pas surprenant, ironique pour tout dire, que lorsque nous examinons le processus de changement d'un logiciel existant, en général nous observons que :
- Le Product Owner (ou le client) a été capable de formuler précisément, en l'isolant au moyen de termes métier précis, la partie exacte du comportement qui était à changer.
- L'équipe de développement a été à même de comprendre parfaitement la nature, la portée ainsi que l'impact du changement du point de vue du métier. L'équipe a également, en quelques heures, pu déterminer avec précision quelles parties du code devraient changer, et comment implémenter ce changement.
- Aucun test unitaire n'a pu être écrit, du fait de : coût d'écriture des tests unitaires, manque crucial de modularité dans le code, menant à un enfer de dépendances lorsque l'on essaye d'écrire même un test simple, menant à la création d'une pléthore de mocks, Etc. Etc. Jusqu'à l'usine à gaz. L'équipe a simplement effectué le changement dans la base de code, et redéployé. Push and Pray.
Dans tout code legacy on trouve cette situation pour à peu près chaque changement :
Facile à décrire, Facile à changer, Impossible à tester.
Dans des contextes où la base de code est volumineuse, lorsque l'on cherche à prévenir les défauts à l'aide de tests, les tests intégrés constituent une stratégie coûteuse, inefficace et illusoire.
La stratégie courageuse, la réponse face à une base de code impossible à tester, consiste à écrire majoritairement des tests isolés. C'est à la fois une stratégie de test, et un remède pour les problèmes de conception qui rendent cette base de code difficile à maintenir.
Pourquoi qualifions nous ainsi cette stratégie ? Parce qu'il faut du courage pour adopter une méthode qui consiste à vérifier chaque comportement distinct de l'ensemble des composants du système, en particulier lorsque le code lui-même ne se prête pas facilement à une approche aussi modulaire, et lorsque tant d'acteurs autour du projet sont plutôt d'avis de prendre des raccourcis sur la qualité, étant bien compris que ces acteurs ne seront pas exposés aux conséquences d'une telle décision.
Par conséquent, dans des situations de ce type, aux prises avec du code legacy, il vaudrait mieux nous assurer que nous disposons de tests isolés pour la base de code, et si ce n'est pas le cas, en écrire. Dans une telle situation, le coût immédiat (en temps, budget et énergie) de cette contre-mesure peut paraître intimidant au point que nous serions tentés de minimiser les coûts, et de négliger cette partie de notre process. Mais il nous faut réfléchir.
Lorsque vous allez au centre commercial en vue d'acheter des chaussures, et que vous cherchez à réduire la dépense, il est intéressant de réfléchir au coût que représente le fait de porter des mauvaises chaussures.
Lorsque vous cherchez à réduire le coût des tests, vous devez réfléchir au coût de ne pas écrire assez de tests.
Ou pour paraphraser Ruskin et sa loi générale de l'équilibre des affaires :
Il est peu sage de payer trop, mais c'est une folie de ne pas payer assez pour ce dont vous avez besoin.
En conséquence, face à la nécessité de changer du code legacy, de quoi avez-vous besoin ?
Vous avez besoin de vous assurer que le code que vous vous apprêtez à changer a des tests, et que vous pouvez exécuter ces tests avant et après changement du code.
Bien sûr, ajouter des tests à une base de code legacy représente un travail long et difficile. La tentation est trop grande, de simplement changer juste cette simple ligne de code, et de redéployer.
Peut-être les régressions sur le code n'auront elles pas d'impact ? Combien êtes vous prêt à payer (en budget, en effort, en temps, en réputation) pour le découvrir ?
Encore une fois c'est une situation difficile car tandis que des informations très simples telles que le budget total ainsi que la date de livraison sont affichées sur les murs, les chiffres d'évaluation du risque ainsi que les attentes réelles en termes de couverture ne sont généralement ni explicités, ni partagés, ni même établis. Ce qui signifie que certains des acteurs du projet, qui prennent part à ou influencent la décision d'écrire des tests ou non, ne sont pas pleinement au fait des conséquences d'une telle décision.
Dépassements en coûts et délais évidents à court terme vs risques informulés à long terme, fossés de communications et attentes non alignées : comme il est dit plus haut, il s'agit d'un problème tout sauf technique.
C'est un problème de management de produit.