Défense et illustration des test isolés - #2

Qu’est-ce que le code legacy ?

"Le code sans test est du mauvais code. Peu importe qu’il soit bien écrit; peu importe à quel point il est élégant, orienté-objet ou encapsulé. Avec des tests, nous pouvons changer le comportement de notre code rapidement et de manière fiable. Sans eux, nous ne pouvons pas réellement savoir si l’état du code s’améliore ou empire.” “En ce qui me concerne, le code legacy est simplement du code sans tests.” Michael Feathers, Working Effectively with Legacy Code

"Ce code n’a pas de tests." Cette constatation me frappe à chaque fois comme le symptôme d’une tâche ni faite ni à faire. Un peu comme si je vous disais : Merci de m’avoir invité à cette randonnée. En passant, je vous signale que je n’ai qu’une seule chaussure de marche en bon état, l’autre étant hors d’usage. Tous les deux pas, je dois faire attention où je pose le pied. Cela me prend du temps. Merci de votre patience.

Le code sans tests est fragile. Ce qui a pris des mois à construire peut s’effondrer à la moindre modification. Conséquences de cette asymétrie: une équipe qui a dépensé beaucoup d’efforts à livrer une première version en production rechigne à modifier le code de manière substantielle une fois qu’il "marche". Le modifier est bien trop risqué.

Lorsque votre code ne peut pas être modifié facilement, mieux vaut avoir fait les meilleurs choix de conception dès le début de votre produit. Si vos développeurs n’ont pas fait les meilleurs choix, ils devront travailler dans un système mal conçu, ou prendre le risque de changements périlleux pour l’améliorer. De toute manière, ces deux inconvénients finiront par se concrétiser tôt ou tard, car toute application qui rencontre un tant soit peu de succès une fois livrée en production fait immédiatement l'objet de demandes d’améliorations de toutes sortes.

Supposons que la conception initiale soit loin d’être idéale. Comment pourrait-il en être autrement ? Le début du projet est le moment où nous connaissons le moins le problème à résoudre, les contraintes qui s’exercent, ainsi que notre capacité à livrer une solution dans le temps et le budget impartis. Notre conception ne peut que refléter cet état de fait. Le temps passant, nous nous retrouvons avec deux problèmes: une accumulation de mauvais choix de conception, et aucun test. Chaque livraison de version en recette est accompagnée de tickets d’anomalies, de longues périodes de test d’acceptation utilisateur, de livraisons retardées en production: à chaque fois, notre enthousiasme initial en prend un coup.

Face à tout cela, notre envie spontanée d’améliorer les choses décline, et finit par disparaître. On sait bien ce qui arrive aux équipes en charge d’une application dont la conception est devenue problématique: leur productivité vacille bien que tout le monde fasse des heures supplémentaires, elles abandonnent tout espoir d’améliorer la conception, et les membres clés de l’équipe mettent à jour leur CV.

Et si on refactorait le code ?

Le remède au fléau des problèmes de conception qui se sont accumulés en l’absence de test n'est pas le refactoring. Le refactoring -- le fait d’améliorer la conception d’une unité de code sans en changer le comportement -- dépend, de manière cruciale, de la présence de tests sur l’unité de code que nous souhaitons améliorer. Sans test il n'y a pas moyen de détecter efficacement les régressions que nous avons pu introduire lors du refactoring. C'est pour cela qu'une base de code sans tests est, comparativement à d'autres bases de code, moins fréquemment améliorée. De plus, les améliorations auront tendance à toucher plus de code, avec plus d'impact. Lorsqu'ils travaillent sur du code sans tests, les développeurs repoussent au plus tard possible -- quand ils l'entreprennent -- toute action de modification de la conception.

Le refactoring est une heuristique qui fait sens dans le contexte d'un projet où chacun écrit systématiquement des tests isolés pour le code en cours de développement. Le refactoring est l'une des trois étapes de l'approche TDD, ce qui signifie que les praticiens de TDD passent un tiers du temps de développement à améliorer le code, un pattern appelé Merciless Refactor dans la méthodologie XP. Le refactoring par lui seul ne constitue pas une aide pour l'équipe qui lutte contre l'écheveau complexe et désordonné des dépendances dans une grosse base de code legacy.

Minute: et le clic droit/Refactor de nos IDEs dernier cri ?

Bien que des progrès aient été fait dans les environnements de développement intégrés en ce qui concerne le réarrangement automatique de code, aucun enchaînement d'action de type clic-droit dans des menus de refactoring ne peut vous emmener d'une base de code legacy à un base de code saine. Cela est dû au fait que le refactoring ne concerne pas tant les relations logiques entre des bouts de code (qu'un ordinateur peut exploiter, pour peu qu'on utilise un langage de programmation fortement typé) que la compréhension et la séparation du rôle fonctionnel de chaque partie du système (à propos duquel l'ordinateur n'est d'aucune aide susbstantielle, à part recueillir des métriques sur le code).

Le moment le plus opportun pour relier notre compréhension d'une partie du comportement du système avec la partie correspondante du code dans la base de code est le moment où nous écrivons des tests isolés sur cette partie du comportement. Dans le cas d'une base de code legacy, cette opportunité a été manquée il y a des mois, voire des années. La complexité accidentelle a eu le dessus, et le système est devenu trop compliqué pour être vérifiable de manière modulaire et exhaustive via des tests isolés. Non pas que ce soit impossible : c'est tout simplement jugé trop coûteux, que ce soit par les développeurs, le Product Owner ou les parties prenantes du projet.

Est-ce à dire qu'une base de code legacy est complètement hors de contrôle ? Pas vraiment. Elle est globalement maîtrisée. Mais toute action visant à améliorer la sécurité des changements sur le code ainsi que la connaissance fonctionnelle de chaque élément du système sera jugée -- par le Product Owner et parfois par les développeurs eux-même -- trop coûteuse pour être entreprise maintenant, et de ce fait remise sine die.

Cette procrastination perpétuelle de l'ajout de tests isolés, ainsi que le respect des budgets et le dédain pour toute action qui n'apporte pas immédiatement de valeur métier : c'est ce qui conduit fatalement les équipes (et leurs clients) dans les marécages du legacy. Les développeurs se sont rendus incapables d’améliorer la modularité et la fiablité de leur code, tout en restant capables d’ajouter des fonctionnalités au profit du client. De tels changements sur le système sont dit « trompeurs ». Un changement est "trompeur" quand il est:

  • Facile à décrire, par le Product Owner
  • Facile à implémenter, par les développeurs
  • Impossible à tester, par qui que ce soit

Une longue série de ce type de changement effectué incrémentalement sur le système aura pour résultat exactement cela: du code legacy, c'est à dire du code sans tests. Les développeurs et les consultants aiment attribuer ce phénomène au "code rot" (métaphore biologique), à la "technical debt" (métaphore du surendettement) ou encore à la "complexité accidentelle". Je l’attribue pour ma part à un désalignement concernant l’état de l’art qui prévalait au début du projet, désalignement qui s'est produit lorsque l'équipe en charge a ignoré, omis, ou délibérément oté de sa boîte à outil, la pratique consistant à écrire des [Tests Isolés].

Il est possible que le projet initial était si élémentaire que personne n’a ressenti le besoin de spécifier et vérifier à l'aide de tests isolés sa compréhension de chaque partie du code.

Il est possible que les développeurs se sont naïvement fait la promesse que ce programme sera un « one-shot », qu'il ne fera jamais l'objet d'une évolution ou d'une réutilisation au sein d’un système plus important.

Il est possible qu'ils ne maîtrisaient pas suffisamment les techniques de test unitaire, de mocking, de refactoring et pensaient que la stratégie habituelle « quelques tests d’intégration et de recette » conviendrait.

Une fois que la douleur induite par l'absence de tests isolés s'impose à l'équipe, il est souvent trop tard pour changer simplement de trajectoire. En effet, un projet accablé par les régressions, les retards chroniques et les corrections en urgence n'est pas le meilleur environnement pour acquérir de nouvelles compétences, parce que cela implique inévitablement de ralentir le rythme et de faire des erreurs.

La question de savoir comment remédier à une situation de projet sans tests ressemble à toute tentative de récupération d'un projet en échec stratégique : embrasser du regard l'étendue de l'échec, et commencer immédiatement à réparer les parties du processus qui ont causé cet échec, tout en gérant les attentes avec lucidité. Au lieu de nier la situation en remettant perpétuellement au lendemain le travail nécessaire à une sécurisation de la base de code, il vaut mieux admettre un échec, comptabiliser les pertes, et préparer les parties prenantes à des résultats insuffisants et plus lents à venir.

Quelles que soient les techniques que les développeurs ont besoin d'acquérir et de commencer à pratiquer : test unitaire, refactoring, tests de caractérisations, approval tests, mocks etc., rien ne leur permettra de s'améliorer tout en maintenant leur niveau habituel de productivité. Aussi vrai que "on irait plus vite sans tous ces bugs et ces problèmes de dépendances", il est certain qu'atteindre un nouveau plateau de pratiques et de process ne se fait pas en un jour.

Après tout, serait-ce réaliste de ma part de vous dire: Oui, je pars avec vous faire cette randonnée et dans le même temps je m'achèterai ou je me confectionnerai une nouvelle paire de chaussures ?