Défense et illustration des test isolés – #3

Diviser et régner

Since three out of four small businesses fail, my recommendation is to start a large business.

Code "Legacy" et Etat de l'Art (SOTA)

Le terme "legacy" s'utilise souvent pour décrire des systèmes informatiques anciens souffrant d'une dette de maintenance majeure accumulée au fil des ans. Mais le code legacy code peut surgir très tôt dans un projet. Même avec les meilleures intention du monde, une équipe dont l'état de l'art (SOTA) est désaligné ou dégradé peut transformer votre projet "greenfield" en une fabrique de legacy, et ce en 3 à 6 mois, avant même que vous n'ayez eu le temps de réaliser ce qui se passe. Le terme "legacy" appliqué au code ne signifie pas que tel framework ou tel language soient passés de mode : il signifie que sur ce projet la livraison de fonctionnalités se fait au compte-goutte, et qu'elle est immanquablement suivie de bugs en production.

Le code legacy existe en partie parce que nous pouvons toujours prendre des raccourcis : plutôt que refactorer en continu, nous dupliquons le code ça et là. Nous choisissons de traiter les bugs une fois qu'ils se manifestent plutôt que de suivre un processus de prévention des défauts adéquat. Pour un moment, nous prenons la vitesse à laquelle nous livrons du code (mal conçu) pour une performance d'équipe remarquable. Les features s'empilent au bout de la chaîne de montage de la software factory tandis qu'un Product Owner qui n'a plus une minute à lui s'occupe de valider leur conformité dans la chaîne de création de valeur. What could go wrong?

Un monde idéal, lorsqu'un projet de développement logiciel commence, un Etat de l'Art (SOTA) est défini pour ce projet, de manière explicite ou implicite. L'état de l'art initial du projet est constitué de tous les procédés heuristiques recensés durant la phase de préparation ou le "sprint zéro", sous l'étiquette : "Comment dont nous allons faire". Une partie de l'état de l'art peut être documenté de manière officielle, par exemple dans un PAQ (Plan d'Assurance Qualité) ou une charte de développement. Une autre partie est implicite, supposée, car elle fait partie des standards de notre industrie. Enfin une troisième partie de l'état de l'art ne sera découverte qu'une fois commencée la conversation en équipe qui caractérise et qu'impose tout projet non trivial.

Voici quelques exemples de procédés heuristiques qu'un SOTA pourrait contenir:

  • [ Indentez votre code ] : l'intégralité du code source suivra les standards d'indentation adéquats du langage utilisé. Bien que personne n'en ait jamais fait mention dans l'équipe, ce procédé est bien présent dans le SOTA, comme standard implicite de notre industrie. En effet, rares sont les développeurs qui ne suivent pas de règles d'indentation dans leur code.
  • [ Métriques de couverture de test ] : sur l'ensemble du projet le code des tests automatisés est instrumenté de façon à mesurer la couverture de branche du code sous test. Cette heuristique est définie dans le PAQ officiel, avec un pourcentage minimal de couverture décidé d'un commun accord.
  • [ Architecture Decision Record ] : chaque décision importante concernant l'architecture sur le projet est consignée de façon à refléter le contexte ainsi que les tenants et aboutissants de la décision pries. Cette heuristique a été proposée par un membre de l'équipe quelques temps après le début du projet, afin de simplifier le processus de décisions techniques et d'améliorer la cohésion de l'équipe.

Etant donnée une base de code legacy en cours de maintenance, il est hautement probale que son SOTA initial n'était d'emblée pas aligné avec les contraintes et les objectifs du projet. Peut être que la stratégie de tests n'était pas définie. Peut être que la complexité d'une fonctionnalité critique était largement sous-estimée, ou que l'on pensait pouvoir la mettre en œuvre entièrement via une "solution sur étagère". Peut être que ce qui semblait une solution raisonnable au départ du projet s'est avéré infaisable par la suite. Peut être que personne n'était tout à fait d'accord sur ce que signifie "interface utilisateur affordante", etc.

Il est bien entendu que les contraintes ainsi que les objectifs peuvent également changer pendant la réalisation, et que cela rend le SOTA initial plus ou moins obsolète. Des nouvelles leçons, décisions, découvertes, expérimentations, déconvenues, dispositions d'équipe, modifications, communications, etc. sont autant de circonstances auxquelles l'activité de développement est confrontée, et qui viennent nécessairement impacter son SOTA. Il est à peu près certaint qu'un tel SOTA n'est jamais établi une fois pour toute, comme une doctrine. Par exemple, les équipes et les organisations qui adhèrent à la stratégie Lean essayent constamment d'améliorer leur Etat de l'Art. Celles qui n'y investissent que peu de réflexion et d'effort voient leur Etat de l'Art se détériorer au point que leur processus et leurs pratiques deviennent grossièrement inadaptés au problème à résoudre.

Avançons en milieu de course d'un tel projet lorsqu'il atteint sa "vitesse de croisière". En fonction du degré de désalignement dont souffre son Etat de l'Art, n'importe quelle proposition pour l'adoption de nouveaux procédés qui permettraient d'obtenir une meilleure conception, un meilleur produit, ou un processus de développement plus efficace, apparaîtra comme une alternative coûteuse, irréaliste, et sera écartée comme de la "surqualité". En fait plus les difficultés, les retards, les anomalies, et autres mauvaises surprises ponctionnent le budget initial, plus l'équipe et l'organistion vont tenter de s'en tenir coûte que coûte à la stratégie initiale, avec pour effet de rendre toute initiative de changement de plus en plus difficile. On voit alors parfois circuler dans la messagerie de l'équipe des images de charette à roues carrées.

Si des personnes dans l'équipe proposent un SOTA amélioré pour un meilleur processus et des pratiques plus efficaces, et que d'autres membres de l'équipe rejettent cette proposition, alors cette proposition restera officiellement "trop coûteuse" jusqu'à ce qu'il soit démontré qu'elle revient moins cher que la démarche actuelle. De toute manière, comme l'équipe paie déjà les coûts supplémentaires et les entorses à la qualité typiques des projets mal conçus pris d'assaut par des nuées de bugs, ce SOTA amélioré est hors d'atteinte. Nous comprenons qu’il permettrait d’atteindre une qualité plus élevée, et cela pour un prix moins élevé. Nous comprenons qu’il aurait permis d’empêcher, pour sûr, tout ce désordre. Mais basculer vers ce processus amélioré – ce qui ne peut ne aucun cas arriver du jour au lendemain – nous semble hors d’atteinte. Nous sommes bloqués, prisonniers d’une stratégie obsolète et sous-optimale que nous ne voulons pas – ou pensons que que nous ne pouvons pas – améliorer, à l’image d’un joueur d’échecs qui, à la suite de mauvaises décisions, a perdu une pièce il y a 5 tours, et est obligé de penser vite et de faire bon usage du temps qui lui reste à l'horloge.

Basculer d’un SOTA donné (celui qui a mené à une solution dégradée, fragile, non-satisfaisante) à un SOTA significativement différent (celui qui permettrait une maintenance et des évolutions rapides sur l’application, tout en minimisant le risque de régressions coûteuses) en l’espace de quelques semaines est impossible. Les personnes avec lesquelles vous travaillez, leurs connaissances, savoir-faires et pratiques, votre cycle de vie et processus de développement logiciel, la culture de votre organisation : tout cela ne changera jamais aussi rapidement. Une approche qui semble logique, bien que radicale, consisterait à remplacer sur ce projet les Technical Leaders et autres personnes "clés". En fait cela ne ferait que ralentir votre progression, jusqu'à l'arrêt complet.

Que faire dans ce cas ?

Et si on essayait une approche de type "Diviser pour régner" ?

Avez-vous déjà été confronté à la tâche de débugguer un vieux programme aussi complexe que mystérieux et capricieux, et qui refuse obstinément de traiter un fichier de données ?

La seule chose que vous savez, c'est que quand on lui soumet ce fichier défectueux de 15 Mo fourni par le client, le programme plante avec un cryptique invalid format. Vous demandez : "oui, mais où ? : malheureusement le programme n'en dira pas plus avant de mourir. Le fichier est constitué d'environ 200 000 lignes de données au format texte, vraisemblablement des nombres et des libellés tout ce qu'il y a de plus innocent. Vous n'avez pas la moindre idée d'où le problème pourrait se trouver. Alors vous faites une copie du fichier, et après avoir un peu étudié sa structure, vous le divisez en deux grâce à votre éditeur de texte favori. Vous soumettez au programme le premier demi-fichier, et rien ne se passe. Le programme a seulement ingurgité le fichier avec un OK et s'est arrêté. Vous soumettez l'autre moitié, et cette fois ci par contre vous obtenez un invalid format. Vous avez de la chance. Vous divisez en deux ce second fichier, obtenant ainsi deux quarts du fichier initial. Vous poursuivez cette procédure jusqu'à trouver l'emplacement exact de la première ligne incorrecte dans le fichier. Cela ne vous a pris que 16 essais avant de pointer cette ligne particulière parmi 200 00. Félicitations : vous venez d'appliquer la stratégie Diviser pour Régner.

En informatique, pour citer Wikipedia,

Un algorithme diviser-pour-régner décompose récursivement un problème en deux sous-problèmes, ou plus, du même type ou d’un type lié, jusqu’à ce que ceux-ci soient assez simples pour être résolus directement. Les solutions des sous-problèmes sont alors combinées pour obtenir la solution au problème initial.

Pour en revenir à la maintenance de notre application legacy, nous pourrions énoncer le problème initial de la manière suivante : Afin d’étendre le comportement de l’application, améliorer sa performance, améliorer son design ou corriger un défaut, nous avons besoin d’appliquer des changements sur le code.

Bien que la plupart de ces changements puissent être délimités clairement, le code n’a pas de tests et il est pour ainsi dire vulnérable à de potentielles régressions.

Par conséquent : a) nous limitons les changements au strict minimum, déclarons le refactoring illégal, et de manière, pérennisons nos erreurs de conception; b) nous remettons au plus tard possible les changements, nous livrons des versions plus volumineuses et moins fréquentes, de manière à minimiser l’effort de test manuel d’intégration et de recette.

Nous voulons arrêter cela. Nous voulons être capables de refactorer le code aussitôt que nous sentons des code smells, ou quand l’écart devient trop grand entre le code et la compréhension partagée du problème que ce code doit résoudre. Nous voulons des livraisons peu volumineuses, fréquentes, routinières. Avoir beaucoup de tests de faible granularité, isolés, répétables et auto-validants est un moyen vers cette fin.

Bien sûr, comme remarqué plus haut, nous n’avons pratiquement pas de tests à grain fin, isolés, répétables et auto-validants. Ecrire de tels tests dès maintenant est impossible, ou bien très coûteux, du fait que le code existant manque de modularité : à chaque fois que nous voulons « tout simplement » appeler une méthode d’un objet, nous nous retrouvons forcés de configurer d’autres objets duquels cet objet dépend, et cette chaîne de dépendances compte souvent des appels à des ressources externes, comme des drivers de base de données, qui doivent être connectés, et ainsi de suite..

Puisque nous ne pouvons pas commencer par des tests de faible granularité, rapide et automatisés, faisons le contraire : commençons par quelques tests de forte granularité, intégrés, et qui impliquent un diagnostic et une validation humaine.

[ Écrivez des tests d'approbation (approval tests) pour le code qui ne peut pas se tester isolément ]:

  1. écrivez un test qui capture une partie (peut être conséquente) de la sortie et la compare à une valeur arbitraire ;
  2. approuvez cette première sortie comme résultat attendu pour les futures exécutions du test ;
  3. à chaque fois que l'exécution du test produit un résultat différent, choisissez entre
    • localiser et corriger la régression qui a causé le changement dans la sortie
    • approuver cette sortie comme la nouvelle référence attendue pour les prochaines exécutions.

Une fois que vous avez suffisamment de tests d'approbation pour couvrir le comportement que vous souhaitez protéger des régressions, vous pouvez commencer à diviser le problème en appliquant la [ Séparation des Responsabilités ] :

  • ré-assignez les parties du comportement du programme à des modules spécifiques
  • créez des interfaces afin d'assurer une bonne encapsulation
  • exécuter les tests après chaque changement fait sur le code.

Cette amélioration de la conception permet d'améliorer la modularité, qui fait que les parties du comportement de l'application qui sont conceptuellement distinctes se trouvent dans des modules distincts. À ce stade, il devrait être possible d' [ Écrire des tests isolés pour les parties du comportement qui sont isolées conceptuellement ], c'est à dire de créer des tests capables d'instancier des objet et de préparer des données, d'exécuter des méthodes ou d'évaluer des expressions de manière à vérifier l'état des objets ou la valeur des résulats, et de faire tout cela indépendamment de toute ressource externe. Si l'écriture de tests isolés n'est pas encore possible sur le code que vous améliorez, continuez à écrire des tests d'approbation, plus spécifiques si besoin.

Une fois que vous disposez de plusieurs tests isolés couvrant le comportement particulier que vous voulez isoler, vous pouvez vous débarrasser de la plupart de vos tests d'approbation. Ces tests, qui s'appuient sur des composants intégrés et des ressources externes, se révèlent maintenant trop coûteux à exécuter comme à modifier, et ils permettent une couverture bien moins largue que ne le fait une suite adéquate de tests isolés.

Vous avez utilisé avec succès l'heuristique Diviser pour régner.

Un exemple serait le bienvenu

Supposons que nous maintenons une application de poker en ligne. Cette application ne dispose d'aucun test automatisé, et il nous a été demanéd de corriger quelques bugs dans la comparaison des mains. Un des tickets mentionne :

  • Dans certains des cas, le showdown montre une évaluation incorrecte des mains. Le joueur avec 6♦ 6♠ 6♣ J♣ 7♥ est déclaré vainqueur au lieu d'un autre joueur qui possède 6♥ 6♦ 6♠ Q♣ 4♠ (nous n’avons pas pu reproduire le bug).
  • La règle est qu'un brelan est d'abord classé par la valeur de la carte en triple, puis par la valeur de la quatrième carte (première carte du kicker), et en cas de quatrième carte identiques, finalement par la seconde carte du kicker.

Après nous être assurés que nous sommes capable d'exécuter l'application dans notre environnement de test, nous procédons dans l'ordre qui suit :

    • créez un premier test d’approbation en exposant dans un fichier de log une variable globale d’état représentant l’ensemble du jeu à chaque showdown; le fichier contient des informations telles que le joueur gagnant, ses gains, et avec quelle main.
      • problème: la distribution initiale des cartes est différente à chaque nouvelle partie, donc les tests d’approbation sortent en échec à chaque exécution.
      • dans le code des tests d’approbation, assignez à la distribution initiale une répartition fixe (en modifiant de la manière la plus légère possible le code sous test, par exemple en exploitant une propriété publique ou une interface); cette répartition fixe se substituera à l'appel de la fonction la fonction de mélange des cartes; faites dépendre cette substitution d’un paramètre d’entrée; à présent, chaque exécution des tests d’approbation utilise le même jeu de cartes.
      • en exécutant régulièrement le test d’approbation, refactorez afin d’obtenir une meilleure séparation des responsabilités, en déplaçant certaines méthodes dans des modules de comparaison de mains, de gestion des mises et de calcul des gains, et de présentation du showdown...
  • créez un second test d’approbation qui puisse comparer les mains des joueurs grâce à l'extraction d'une variable vers la sortie.

  • en exécutant régulièrement les test d’approbation, refactorez encore afin d’obtenir une séparation des responsabilités, en déplaçant certaines méthodes dans des modules d’état du jeu, d’évaluation de la main, et de comparaison de mains.

  • écrivez un test isolé sur l’évaluation de la main: Brelan avec un même rang pour les kickers (égalité complète des cartes)

  • écrivez un test isolé sur l’évaluation de la main: Brelan avec des rangs égaux pour le triplet et des rangs différents pour le kicker de rang le plus élevé: le bug se produit.

  • écrivez un test isolé sur l’évaluation de la main : Brelan avec des rangs égaux pour le triplet et des rangs différents pour le kicker de rang le plus élevé, mais cette fois avec des cartes triées par rang: le bug ne se produit pas.

  • corrigez le bug

  • refactorez vers plus de séparation des responsabilités: créez le module de comparaison du rang, de groupement des cartes, de classement par catégorie de main...

  • etc.

Conclusion

Dans la plupart des contextes de code legacy, introduire progressivement des tests isolés dans le code constitue la stratégie la plus sûre et la plus efficace, permettant à l’équipe de se frayer un chemin en dehors du piège legacy.

Les tests isolés sont vos alliés:

  • ils vous donnent un retour rapide et fiable, et peuvent être exécutés dès que le code est compilé
  • ils sont auto-suffisants, dans la mesure où ils ne requièrent aucune préparation manuelle de jeux de données ni d’étapes de gestion de l’environnement de test
  • ils contribuent à l’amélioration de l’agentivité de l’équipe, clarifient les propriétés réelles et le comportement du système qu’ils permettent de maintenir, contribuant ainsi à documenter ce système et à en améliorer la conception

En suivant de manière répétée cette séquence stratégique:

  1. écrire des tests d’approbation pour le code qui ne peut pas être testé de manière isolée
  2. refactorer vers la modularité, en appliquant l’étape 1) jusqu’à pouvoir passer à l’étape suivante
  3. écrire des tests isolés

nous pouvons mettre le code legacy sous tests et ainsi accomplir notre objectif de sécuriser chaque changement que nous effectuons sur le code Cette voie est plus rapide que d’essayer immédiatement d’écrire des tests isolés sur le code : au lieu de créer un réseau complexe d’objets de type mocks pour se débarasser des dépendances externes, nous utilisons les tests d’approbation comme solution de contournement pour nous aider à sécuriser les premières actions de refactoring. Dans cette heuristique, les tests que nous écrivons nous aident à sécuriser de manière efficace les changements effectués sur le code, à l’image d’un étau qui immobilise les pièces mécaniques sur lesquelles nous travaillons. étau logicielCette voie est également plus rapide que celle qui consiste à essayer d’obtenir un taux de couverture élevé via des tests intégrés : au lieu de gérer des jeu de données externes et des environnements pour couvrir une large combinaison de casr nous créons des tests « unitaires » plus simples (l’unité désignant ici le comportement spécifique que nous isolons, pas nécessairement un seul fichier de code, une seule classe ou une seule méthode).

Bien sûr, les tests intégrés continuent à être utilisés dans le projet, mais uniquement pour ce pour lesquels ils sont conçus, c’est à dire vérifier l’assemblage et la bonne intégration des composants, et non pas vérifier le comportement complet des composants.

Et ensuite ?

Toutes les bases de code, qu’elles soient le résultat d’un projet à succès ou d’un projet laborieux, finissent par se transformer en code legacy. Cela n’est pas nécessairement un _problème_ : tout dépend des buts et contraintes spécifiques qui s’appliqueront lors du travail ultérieur avec cette base de code. Cela peut devenir sans aucun doute un problème si vous n’êtes pas conscients, ou si vous niez, que le code qui joue un rôle si important dans votre succès commercial est devenu du code legacy.

Mais qu’est-ce que du code legacy après tout ? Il s'agit de code qu'il est difficile, coûteux et risqué de faire évoluer. Or qu’arrive t'il inévitablement après la livraison de la première version d’un projet à succès, sinon des évolutions ?

Parfois, les personnes en charge de la maintenance d’une base de code, ou les consultants chargés d’inspecter le processus et de réduire le « time to market » formuleront le problème en utilisant le terme "dette technique" en lieu et place de "code legacy". Certains iront même jusqu’à assigner un montant à cinq ou six chiffres au coût du problème. Mais qu’est-ce que la dette technique ? C’est en réalité la même chose que ce que nous désignons depuis le début par le terme "code legacy" : un désalignement dans l’état de l’art spécifique de cette application, un _écart_ devenu suffisamment important pour entraver la moindre des améliorations fonctionelles ou techniques que vous voulez apporter à votre produit.

En supposant qu'on veuille s'_occuper_ de ce problème, au lieu de continuer à l’ignorer, quel est le meilleur chemin ? Nous proposons celui-ci:

  • dès que vous voulez modifier du code, écrivez des tests isolés
  • écrire des tests isolés sera probablement difficile au début, aussi écrivez plutôt quelques tests d’approbation
  • puis divisez pour régner: ouvrez votre chemin via le refactoring puis écrivez des tests isolés sur votre legacy

Bon courage !