Via Negativa : TDD et la conception de logiciel

(Dans cette série d'articles, nous nous inspirons de la Via Negativa pour partir à la recherche de pratiques robustes, basées sur des connaissances négatives, et dont le principe est d'identifier rapidement et avec certitude ce qui ne fonctionne pas, afin de construire un système plus solide.) Dans son discours de réception du Prix Turing en 1980, Charles A.R Hoare, évoquant l'histoire de la conception et des implémentations du langage Algol, fait cette observation devenue fameuse :

There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies.

Il précise ensuite que des deux méthodes, la première est de loin la plus difficile.

Tout développeur un peu chevronné en a fait l'expérience : il est relativement facile de concevoir un système si compliqué qu'il en devient obscur. Il suffit de laisser les éléments de conception s'y ajouter "naturellement", comme par accrétion, jusqu'à ce que le système atteigne le stade dit de "l'usine à gaz", à partir duquel il est impossible d'y déceler des défauts évidents.

Face à une usine à gaz — comme face à toute calamité ayant pour origine un processus organisé — deux questions se posent :

  • Que pouvons-nous apprendre d'une telle erreur (ou série d'erreurs) ?
  • Sera-t-il possible un jour d'éviter des désastres similaires ?

Dans cet article, on présente, vu sous l'angle de la via negativa, un outil de conception simple, imparfait et insuffisant, mais dont nous pensons que l'adoption peut conduire à réduire les risques de développement d'usines à gaz.

Fragilité et Feedback

La formulation de Hoare, à savoir qu'une conception peut être simple au point qu'il est évident qu'elle ne contient pas de défaut ou bien compliquée au point qu'elle ne contient aucun défaut évident, rappelle, au moins dans sa forme, la confusion périlleuse qui menace selon Taleb toute interprétation fragiliste des phénomènes socio-économiques (ou autres) :

L'absence de preuve n'est pas une preuve d'absence.

Un système est dit fragile lorsqu'il recèle les possibilités de sa ruine et que les acteurs qui l'utilisent ou en tirent profit ne peuvent pas détecter cette possibilité à l'aide de leur modèle courant. Dans un système fragile, les mauvaises nouvelles ne se laissent pas facilement découvrir à la lecture des données du passé. On confond alors l'absence de preuve avec la preuve de l'absence, et on déclare le système robuste, jusqu'au moment où le système s'écroule. (Les exemples les plus frappants sont constitués par les crises financières).

Si l'on considère l'impact de sa conception sur sa fiabilité une fois en production, une "usine à gaz" constitue, toutes proportions gardées, un système fragile. Étant donnée une application non triviale, qui s'est compliquée par accrétion (une autre métaphore serait : qui est endettée), ce n'est pas en scrutant le code, la spécification ou les rapports de tests, même pendant longtemps, que l'équipe qui maintient cette application pourra prédire la date, l'impact et la nature exacte de la prochaine défaillance en production. Ce qui ne l'empêchera pas d'affirmer (ou de suggérer) que "l'application marche", et que si elle devait ne pas marcher, ce n'est peut-être pas si grave. La plupart des applications non triviales sont donc fragiles, mais pas au point de provoquer la ruine de leur utilisateurs (et certainement pas de leurs responsables). On peut dire qu'elles sont "localement" fragiles, car l'illusion de l'absence de preuve comme preuve d'absence persiste : pour l'équipe aux prises avec un logiciel endetté (comme pour son manager), tout va plutôt bien dans l'ensemble, sauf quand tout va mal. Comme le dit astucieusement Kent Beck, "l'optimisme est une maladie professionnelle du programmeur". Et d'ajouter : "Le remède s'appelle Feedback".

Malheureusement le feedback qui se manifeste sous la forme de rapports d'incidents (même catastrophiques) n'est pas de nature à aider les programmeurs, pour trois raisons :

Le feedback se manifeste trop tard : à la date où l'incident survient, les développeurs qui n'ont pas quitté l'organisation sont de toute évidence occupés à de nouvelles tâches, et la résolution de ce "ticket" qui interrompt leur travail représente a) un effort de retour en arrière souvent considérable, b) une énigme (d'intérêt relativement médiocre, pour la plupart des développeurs), c) en tout cas une affaire à classer au plus vite.

Le feedback est confus : l'information qui serait directement utile aux développeurs est enfouie dans les "décombres" de l'incident : les circonstances, les conditions d'utilisation, l'enchaînement des causes, les contributions d'autres acteurs, l'état des données, l'historique des modifications, tous ces facteurs ayant abouti à l'incident doivent être rigoureusement analysés si l'on veut obtenir une information suffisamment pertinente pour changer le cours des choses (c'est à dire améliorer le process). Le feedback est "chargé" négativement : il est rare que la nouvelle d'un incident en production soit accompagnée d'un encouragement tel que :

"voici une remarquable opportunité pour vous d'en apprendre plus sur la conception de ce système, ainsi que la robustesse de notre process".

La plupart du temps, la consigne implicite ou explicite est de remédier au plus vite. Pour l'anecdote, une équipe que j'ai rencontré a même eu droit à un email venant du plus haut de la hiérarchie, intitulé (c'est moi qui censure) :

"quel est le c.. qui a touché à ce programme ?".

Il est difficile d'apprendre de nos erreurs lorsqu'elles nous sont mises sous le nez avec brutalité. Dans ces cas-là nous cherchons plutôt la porte de sortie que la clé de l'énigme.

Une session de Test Driven Development

Quelle sorte de feedback constituerait donc un remède efficace à l'optimisme du développeur ? Nous cherchons un feedback rapide, précis, et qui ne soit pas connoté en termes d'échec, ou même en termes d'incident; en somme qui soit mieux intégré au processus de construction du logiciel. Kent Beck, (dans son livre, TDD by example) propose une démarche basée sur deux règles simples :

Écrivez du code nouveau seulement si un test automatique a échoué.

Éliminez la duplication.

Ces règles impliquent le cycle de programmation élémentaire suivant :

_- Rouge — Ecrivez un petit test qui ne passe pas, qui ne compile peut-être même pas au début.

  • Vert — Faites passer le test rapidement, en commettant des fautes au besoin.
  • Refactor — Éliminez toute duplication créée en vue de faire passer le test._

À titre d'exemple, voici une conversation typique qui aurait lieu entre deux personnes développant une fonction simple en TDD (vous pouvez sauter cette section si la programmation vous ennuie) :

Alice : On a besoin d'une fonction capable de valider le numéro de compte au moyen d'une somme de contrôle.

  • Le numéro de compte est une chaîne de caractères composée de 9 chiffres : c1, c2, c3, c4, c5, c6, c7, c8, c9.
  • Un numéro de compte est dit valide si sa somme de contrôle est un multiple de 11.
  • La somme de contrôle est calculée comme suit : (c1*9 + c2*8 + c3*7 + c4*6 + c5*5 + c6*4 + c7*3 + c8 * 2 + c9).

Voici des exemples de numéros de compte valides :

000000000 : 0 modulo 11 = 0 130000000 : 1 * 9 + 3 * 8 = 33, 33 modulo 11 = 0 000000051 123456789 490867715 999999990

Bertrand : Je vois. Il faut donc multiplier les chiffres respectivement par 9, puis 8, puis 7, etc, les additionner, et dire si le modulo 11 de cette somme est égal à zéro. Alice : Voilà. La liste des comptes en exemple nous fournit une bonne idée de progression pour les tests. Bertrand : Commençons par le cas trivial. Si tous les chiffres égalent 0, le modulo 11 de la somme égale 0. Voici le premier test:

assert valide("000000000")

Alice : Je triche :

def valide(compte): return True

Bertrand : OK. Voici un second test : ce compte ne devrait pas être valide :

assert not valide("000000001")

Alice : Je triche toujours :

def valide(compte): return compte == "000000000"

Bertrand : Bien. Prochaine étape : validation d'un numéro de compte comportant deux chiffres non nuls. Voici un cas de test :

assert valide("000000019")

Alice : Je triche encore une fois pour faire passer le test

def valide(compte): return compte == "000000000" or compte == "000000019"

Bertrand : Ok. Voilà un troisième exemple

assert valide("000000027")

Alice : Je triche encore une fois :

def valide(compte): return compte in ["000000000","000000019","000000027"]

Bertrand : Cela nous fait trois valeurs en dur. Est-ce qu'on peut généraliser le code ? Alice : Oui. Il suffit d'extraire l'avant-dernier et le dernier chiffre du compte pour calculer la somme. Bertrand : On peut convertir le numéro de compte en entier, ensuite isoler les unités et les dizaines… Alice : Ah oui, tiens, bonne idée. Commençons par convertir le compte :

def valide(compte): return int(compte) in [0,19,27]

Bertrand : Note que ce n'est peut-être pas une bonne idée : rien ne garantit que le format de cette donnée "numéro de compte" va rester numérique à l'avenir. Alice : Dans ce cas, l'algorithme de validation devra changer également ! Bertrand : Ce n'est pas faux. Alice : Comment récupérer les dizaines et les unités ? Bertrand : Divise par 10 pour les dizaines, modulo 10 pour les unités. Alice : Je vois.

def valide(compte): valeur= int(compte) unites = valeur % 10 dizaines = valeur / 10 return (dizaines*2 + unites) % 11 == 0

Et tous les tests passent ! Bertrand : Parfait. Voici un test qui amène à un chiffre supplémentaire à intégrer dans le contrôle :

assert valide("000000116")

Alice : Il suffit de convertir l'avant-avant-dernier chiffre, de le multiplier par 3 et de faire la somme :

def valide(compte): valeur= int(compte) unites = valeur % 10 dizaines = valeur / 10 centaines = valeur / 100 return (centaines*3 + dizaines*2 + unites) % 11 == 0

Ah oui, mais non, le calcul des dizaines n'est pas correct..

def valide(compte): valeur= int(compte) unites = valeur % 10 dizaines = (valeur / 10) % 10 centaines = valeur / 100 return (centaines*3 + dizaines*2 + unites) % 11 == 0

Voilà, c'est correct. Bertrand : Comment supprimer les redondances dans cette fonction ? Alice : Avec une boucle de 1 à 3.

def valide(compte): valeur= int(compte) somme = 0 for facteur in range(1,3): somme += (valeur % 10) * facteur valeur /= 10 return somme % 11 == 0

Tiens, non, ça ne marche pas : le dernier test ne passe pas… Bertrand : La boucle ne s'exécute pas jusqu'aux centaines… Alice : Ah oui, pour un range la limite haute est exclue. C'est 4 et non 3 qu'il faut.

def valide(compte): valeur= int(compte) somme = 0 for facteur in range(1,4): somme += (valeur % 10) * facteur valeur /= 10 return somme % 11 == 0

Bertrand : Parfait. Voici un test qui va nous amener à intégrer tous les chiffres du compte dans le calcul de la somme :

assert valide("123456789")

Alice : Simple : il suffit de changer la portée :

def valide(compte): valeur= int(compte) somme = 0 for facteur in range(1,10): somme += (valeur % 10) * facteur valeur /= 10 return somme % 11 == 0

Bertrand : C'est très bien. Je pense qu'on en a fini avec le cas nominal. Il faudrait soumettre le module aux tests de recette. Alice : Je ne crois pas qu'on ait fini. Cette fonction comporte des limitations qu'il faudrait au moins expliciter… Bertrand : Oui, les cas où le compte ne contient pas neuf chiffres, entre autres…

Une promesse de robustesse et de flexibilité

Cette session appelle plusieurs remarques :

1 Durant cette session, Alice et Bertrand ont abouti progressivement à une implémentation générale de la fonction valide() au moyen de deux patterns caractéristiques de TDD :

Fake it 'til you make it ✓ J'ai écrit un test et vérifié qu'il ne passe pas ✓ Je ne connais pas encore l'implémentation correcte qui passerait ce test ➮ Renvoyer une valeur constante afin de revenir à l'état Green

Triangulate ✓ J'ai écrit au moins un test que j'ai fait passer en trichant (Fake it) ✓ Je ne connais pas encore exactement l'implémentation générale ➮ Écrire et faire passer encore un ou plusieurs tests ➮ Remanier le code afin de faire émerger la solution générale

2 Alice et Bertrand (ils ne sont pas les seuls) commettent un abus de langage en appelant "test" la partie de leur code qui effectue les assertions. La notion de test couvre une activité bien plus large que celle qui consiste à exécuter une assertion, et on devrait ici plutôt parler de simple vérification : le fait de s'assurer qu'un code s'exécute avec le résultat attendu. 3 Chaque vérification ajoutée au code produit une certaine connaissance négative à propos de leur programme, à savoir qu'une fonctionnalité spécifique n'existe pas encore, ou pas encore complètement. À ce titre, il n'y a qu'une différence minime de sémantique entre un test TDD qui ne passe pas (étape Rouge) et un rapport de bug :

prochaine étape : validation d'un numéro de compte comportant deux chiffres non nuls. Voici un cas de test : assert valide("000000019")

ticket 4807 : j'ai remarqué qu'appliquée à un numéro de compte comportant deux chiffres non nuls (000000019) la fonction valide ne retourne pas un résultat correct (ici, False au lieu de True).

C'est comme si la conception se construisait à partir de rapports (très succincts) d'anomalies sur des fonctionnalités manquantes. Nous retrouvons la force spécifique des constructions par négations, ou via negativa : de la même façon qu'une revue de code améliore la robustesse du code en y relevant des défauts, TDD permet de construire un module de la façon la plus robuste qui soit : chaque ligne de code est arrivée parce qu'une vérification ne passait pas.

4 La robustesse acquise par des vérifications automatiques successives apportées au code en train de se construire est particulièrement élevée. À moins de perdre les fichiers sources ou bien de rompre avec le cycle Red/Green/Refactor, le code ainsi écrit est protégé à vie contre les régressions. C'est ce que Beck désigne par le terme d'effet cliquet : un cliquet permet d'appliquer une force (e.g. lever une charge) par étapes, c'est à dire de relâcher l'effort sans perdre le fruit des efforts déjà réalisés. De la même manière, les vérifications posées sur le code permettent au développeur de se délester (le temps d'une pause, d'une nuit de sommeil, ou d'une semaine de vacances) de la charge cognitive liée à la difficulté de la tâche sans pour autant risquer d'introduire des régressions dans le code qu'il produit.

1) Modifications au code 2) Vérifications automatisées 3) Remaniements

5 La fonction codée par Alice et Bertrand constitue un exemple trivial (désolé). Je pourrais écrire la fonction valide sur une feuille de papier et la vérifier mentalement sans commettre une seule erreur. Cela ne me garantirait certes pas contre les erreurs d'implémentation, et n'oblitérerait aucunement la nécessité de dérouler d'autres tests plus poussés impliquant l'environnement d'utilisation et la validité des arguments transmis à la fonction. D'ailleurs ces tests s'imposent également à Alice et Bertrand, bien que leur code soit couvert par des (micro-)vérifications. Par conséquent pour une fonction simple comme valide, la démarche classique consistant à

  • élaborer, par écrit, une conception,
  • implémenter cette conception,
  • vérifier unitairement et tester cette implémentation,

reste parfaitement appropriée. À moins d'être particulièrement fatigué ou lunatique, personne n'aurait l'idée d'utiliser un cliquet pour lever une charge de 50g. Ou de 5kg. Your mileage may vary.

6 Ce qui est vrai à propos des petits programmes (comme la fonction valide) ne l'est plus pour les très grands programmes, en particulier ceux qui reçoivent le surnom "d'usines à gaz". Dans un petit programme — écrit sur un bout de papier — je peux déceler une erreur de conception comme le nez au milieu de la figure. Le feedback est, comme avec TDD, immédiat. Dans un grand programme, les défauts sont comme des aiguilles dans des bottes de foin. Le feedback est très long : il faut un certain temps pour qu'un défaut se manifeste — éventuellement — sous la forme d'un incident en production ou d'une anomalie détectée en recette. Nous retrouvons alors les caractéristiques du feedback long, imprécis, et décorrélé de la construction.

7 Bien que l'exemple déroulé par Alice et Bertrand ne présente somme toute que peu de défis sur le plan de la conception, il s'agit tout de même d'une session dans laquelle les activités de conception et d'implémentation sont parfaitement imbriquées. Chaque vérification formule un cas d'usage de la fonction telle qu'elle serait vue de l'extérieur, c'est à dire d'un module appelant. Les choix spécifiques d'implémentation d'un module donné ne se reflètent pas dans le code des vérifications de ce module. En TDD, il n'y a donc pas moyen — ni de raison — de vérifier une méthode privée ou une particularité de l'implémentation en y accèdant de l'intérieur. Cette influence du code appelant sur la conception du code appelé est proéminente. Qui plus est, la robustesse apportée par les vérifications permet de remanier le code (refactoring), c'est à dire d'en changer la structure tout en préservant son comportement. Le code ainsi construit devient donc flexible, c'est à dire ouvert pour les changements et robuste face aux erreurs.

Un outil insuffisant et indispensable

Récapitulons ce qui caractérise la démarche Test Driven Developement :

  • Je code un module en faisant passer une à une des vérifications.
  • Les vérifications automatisées protègent les futurs remaniements du code.
  • Je maximise l'expressivité du code.
  • Le code devient un support flexible de la conception.

Ce dernier point est probablement celui qui a provoqué le plus de controverses lorsque TDD a été formulé par Kent Beck puis popularisé par la communauté XP. Il entre en contradiction avec la "tradition" du génie logiciel (software engineering), à laquelle semble souscrire Leslie Lamport (prix Turing 2013) par exemple, dans sa conférence intitulée Thinking above the code :

If you have problems writing a piece of code, you are not going to solve them by coding harder. You need to think harder above the code level. […] Coding should be easy. The hard part should be the thinking that goes on above the code level, getting the high level specification of what it does right. If you find coding hard, you should stop coding and write a spec of how the code should work.

S'il est vain, comme le fait remarquer Lamport, de remanier le code d'une solution dont l'architecture ou l'algorithme sont a priori inadéquats, il serait fallacieux d'en conclure que la conception logicielle peut être complètement séparée de son implémentation sous forme de code. (Une telle séparation paraissait déjà suspecte il y a un demi-siècle lors de la première conférence de l'OTAN sur le génie logiciel).

Il serait possible de mener des heures durant une discussion sur la conception de logiciel sans pour autant obtenir une définition qui satisfasse tous les participants. Aussi contentons nous de répondre à deux questions de robustesse concernant la technique TDD :

1) La maîtrise de TDD suffit-elle pour réussir un développement ?

Non.

Certes, TDD permet de mettre en place un cycle de feedbacks rapide, précis et constructif à propos du code en cours de réalisation. Néanmoins pour réussir un développement logiciel, de nombreuses autres boucles de feedbacks sont nécessaires, notamment celles qui impliquent le client, les utilisateurs, les architectes, les testeurs, etc.

TDD permet d'écrire un code robuste aux erreurs de programmation, en particulier celles qui sont insérées lorsque l'on doit "retoucher" le code. De toute évidence, de nombreuses autres sources d'erreurs menacent la réussite de votre développement :

  • problèmes d'intégration des composants,
  • conditions inattendues,
  • conception fonctionnelle inadaptée, ambiguë, incohérente,
  • erreurs de communication et de coordination,
  • etc.

TDD fait du code un matériau flexible pour la conception. Cependant la conception d'un logiciel implique beaucoup d'autres activités :

  • élaboration des objectifs,
  • expérimentation avec les utilisateurs et observation,
  • architecture,
  • recherche,
  • tests, revues,
  • etc..

2) Dans quelles circonstances peut on se dispenser de TDD ?

La pratique de TDD est superflue dans les cas suivants :

  • Vous disposez déjà d'une technique de programmation accessible qui rend votre code robuste, flexible et facilement compréhensible par d’autres.
  • La conception de votre logiciel est faite entièrement en amont de l'implémentation et n’est pas susceptible d’évoluer.
  • Vous aimez les défis que pose la correction de défauts en urgence dans un code difficile à faire évoluer.
  • Vous réalisez un logiciel dont le code entier tient sur quelque pages et n’a pas vocation à grossir plus.

Si aucune de ces conditions ne correspond à la situation de votre projet de développement, TDD peut vous être très utile.