Comment faire un test unitaire ? Utiliser le principe FIRST

Utiliser une librairie ou un framework de test ne correspond qu’à l’outil que vous devez prendre en main pour écrire des tests unitaires. Pour bien manier la hache, il est indispensable de connaître la théorie au préalable. Comme dit l’adage bien connu d’Abraham Lincoln : « Que l’on me donne six heures pour couper un arbre, j’en passerai quatre à préparer ma hache.« 

NB: cet article avait été originellement écrite sur mon précédent blogue le 1er janvier 2022. Mais il reste d’actualité.

Dernièrement, j’avais tourné mes toutes premières vidéos de Live Coding sur ma chaîne YouTube, durant laquelle je m’exerçais à la pratique du Test Driven Development (TDD). En les regardant entièrement, il apparaît assez clairement quelques manques qui laissent la conclusion de fin de vidéo en demi-teinte. J’avais été l’exemple vivant de l’importance d’aiguiser sa hache avant de s’en servir. L’une des préparations indispensable correspond à ce que je m’apprête à vous livrer grâce à cet article : l’adoption du principe F.I.R.S.T !

Comment ce principe peut vous aider à faire un test unitaire ? Pourquoi est-ce qu’il est autant répété et pourtant peu appliqué ?

La première ressource citée par les confirmés provient du livre Clean Code de Robert Cecil Martin (surnommé Uncle Bob). Dans le chapitre 9 consacré aux tests unitaires, il décrit les cinq règles suivantes afin d’écrire de bons tests.

Fast
Test should be fast. They should run quickly. When tests run slow, you won’t want to run them frequently. If you don’t run them frequently, you won’t find problems early enough to fix them easily. You won’t feel as free to clean up the code. Eventually the code will begin to rot.

Independent
Tests should not depend on each other. One test should not set up the conditions for the next test. You should be able to run each test independently and run the tests in any order you like. When tests depend on each other, then the first one to fail causes a cascade of downstream failures, making diagnosis difficult and hiding downstream defects.

Repeatable
Tests should be repeatable in any environment. You should be able to run the tests in the production environment, in the QA environment, and on your laptop while riding home on the train without a network. If your tests aren’t repeatable in any environment, then you’ll always have an excuse for why they fail. You’ll also find yourself unable to run the tests when the environment isn’t available.

Self-validating
The tests should have a boolean output. Either they pass or fail. You should not have to read through a log file to tell whether the tests pass. You should not have to manually compare two different text files to see whether the tests pass. If the tests aren’t self-validating, then failure can become subjective and running the tests can require a long manual evaluation.

Timely
The tests need to be written in a timely fashion. Unit tests should be written just before the production code that makes them pass. If you write tests after the production code, then you may find the production code to be hard to test. You may decide that some production code is too hard to test. You may not design the production code to be testable.

Prenons le temps de regarder ces quelques lettres pour comprendre pourquoi est-ce si important.

F comme Fast

Rapide comme moins d’une dizaine de millisecondes, si l’on se réfère au livre Pragmatic Unit Testing in Java 8 with JUnit de Jeff Langr (chapitre 5).
Certains répondront qu’il suffit de ne lancer que les tests en cours d’écriture, puis tous les autres une fois la fonctionnalité terminée. Ceci est une fausse bonne idée. Par expérience, à la fois la mienne et celle des deux auteurs cités, on se retrouve souvent à casser du code quelque part. Puis, on s’en rend compte de ceci que lorsque l’on a la bonne idée de lancer tous les tests. C’est-à-dire pendant l’euphorie de la mission accomplie, se voyant déjà se vanter au prochain daily stand up: « j’ai déjà terminé ma tâche« . Vient alors le moment de fusionner la branche. On se dit que l’on va faire une dernière vérification. Et là, c’est le drame.

Je me suis rendu compte avant de merge que j’ai cassé des tests unitaires, je ne sais pas pourquoi.

Cette phrase, je l’ai réellement sortie lors d’un daily stand up. Mon excuse ? Je trouvais les quelque 6000 tests unitaires un peu long à se lancer. Couplé à ma volonté d’exécuter très souvent mes tests unitaires en cours, je ne pouvais pas me permettre d’attendre une dizaine de secondes pour chaque changement que j’effectuais. Sur le long terme, c’est la défiance des tests unitaires et la question de leur utilité qui se posera.

La piste élaborée par Jeff Langr concerne les dépendances du code. Si pour lancer un test, il est nécessaire d’attendre l’initialisation d’un quelconque système (base de données, API, lecture d’un fichier du système, montage du DOM), votre test dépassera largement la dizaine de millisecondes préconisée. C’est pourquoi il est indispensable d’isoler proprement le système sous test. Isoler ou le rendre indépendant, c’est la deuxième lettre du principe.

I comme Independent

Uncle Bob parle d’indépendance pendant que Jeff Langr et d’autres parleront d’isolation. Ce sont deux notions assez proches, mais les deux auteurs finissent par dire la même chose : il doit être possible de lancer chaque test dans n’importe quel ordre à n’importe quel moment.

Pour cela, un test devra se concentrer sur un comportement suffisamment petit et ne pas dépendre d’un résultat externe. Une bonne pratique consiste à utiliser une pratique empruntée du Behaviour Driven Development (BDD) : le Arrange Act Assert (AAA) ou son équivalent Given When Then.

// Arrange
const { password, isValid, errorMessages } = cases;

// Act
const result: IResult = passwordValidation(password);

// Assert
expect(result.isValid).toEqual(isValid);
expect(result.errorMessages).toEqual(errorMessages);

C’est une discipline à adopter. Dans la pratique des tests automatisés parfois enseigné à l’école, on nous apprend à utiliser des scénarios afin d’écrire ces tests. Le contrecoup de ceci est le non-respect de la lettre I du principe FIRST. Le AAA vous donne les trois informations nécessaires :

  1. Arrange: l’état nécessaire pour le test, ce que les dépendances sont supposé nous livrer.
  2. Act: le seul exécutable à lancer.
  3. Assert: le résultat attendu.

Pour appuyer ceci, Jeff Langr nous suggère l’idée de n’avoir qu’un seul assert par test. Ce à quoi Uncle Bob précise dans son livre que cela peut ne pas être tout le temps vrai et qu’il peut être utile d’avoir plusieurs asserts. Je vous laisse lire ou relire le chapitre 9 de Clean Code si cela vous interroge.

Une seconde suggestion de l’auteur du livre Pragmatic Unit Testing in Java 8 with JUnit concerne le respect de la première lettre du principe SOLID : Single Responsibility Principle (SRP) démocratisé entre autres par Uncle Bob: une classe doit être suffisamment petite et n’avoir qu’une seule raison de changer. L’effet de bord sera peut-être un seul assert puisque la classe n’aura qu’un seul but.

R comme Repeatable

À chaque fois que l’on répète un test, quelle que soit la machine ou le moment de le lancer, le résultat doit être identique. J’adore cette lettre parce qu’elle cache très souvent des faux positifs lors des tests unitaires.

Une fois de plus, Jeff Langr martèle le côté isolé des tests, y compris du système utilisé. L’exemple cité est celle de la date courante. Imaginons que vous développez un système d’inscription possédant une date limite. Maintenant, le développeur utilise la date d’aujourd’hui du système, le compare à une date fixée dans le futur, et voit que le test fonctionne. Mais une fois la date dépassée, ce ne sera plus vrai. C’est pourquoi l’auteur propose de mettre en évidence cette dépendance en utilisant une classe externe.
À des fins de tests, la classe donnera la date souhaitée. En production, si personne ne lui renseigne la date, elle utilisera celle du système courant.

Pour voir un exemple en TypeScript, je vous suggère la vidéo de Mickaël Wegerich.

S comme Self-Validating

Le but d’écrire des tests unitaires est de vous faire gagner du temps. Certains pratiques de développement consistent pourtant à écrire une fonction main() qui appelle plusieurs petites méthodes avec une écriture sur console pour savoir où l’on est. Autrement dit, lancer l’intégralité du code pour vérifier si tout fonctionne bien.

Avec un œil extérieur, il apparaît que cette pratique est relativement longue pour développer. D’autant que le volume de ligne de console affiché ne vous fera pas lire tout ce qui se passe et donc manquer des éléments importants. Lors d’une précédente mission, il m’était arrivé d’avoir une application qui me jetait une erreur noyée dans un amas de console.log() que je n’ai évidemment pas vu. Cela m’aurait pourtant fait gagner du temps s’il existait un moyen simple et suffisamment explicite pour me dire que le code de production fonctionne parfaitement. C’est exactement la lettre S du principe FIRST: le test doit être explicite quand il échoue ou non.

T comme Timely

La plupart des entreprises, y compris dans les équipes déjà bien formées aux tests unitaires, il est commun d’écrire les tests après avoir écrit le code de production. C’est, en effet, plus confortable dans ce sens et surtout, on sait ce qu’on écrit : tester, c’est douter comme dirait l’autre.

L’inconvénient soulevé par Jeff Langr, c’est que les chances pour que vous retourniez sur votre code déjà établi pour écrire une vérification du bon comportement diminue avec le temps. À moins que le manager vous oblige à écrire des tests pour passer en production, écrire un test pour un code déjà écrit apparaît comme une perte de temps. Un moyen de rallonger le temps passé sur une tâche que Monsieur le développeur avait estimé à un jour homme. Les adeptes appelleront plutôt ça des tests de verrouillage.

En novembre dernier, j’assistais à une conférence nommée « Le non-unitaire du TDD » d’Agile Grenoble 2021 durant laquelle le conférencier avait fait une suggestion très juste sur la raison pour laquelle un test est préférable d’être écrit avant le code de production : comment savoir si mon test est bon ou pas ?
S’il est écrit après, il pourra servir au refactoring ou à la prochaine fois où la fonctionnalité aura besoin d’évolution. Donc au prochain développeur qui peut être une personne différente que soi. Par conséquent, le feedback de l’écriture du test n’apparaîtra que longtemps dans le futur, lorsque vous ne vous souviendrez même plus de l’avoir écrit. Que se passe-t-il si le test n’était pas bon ?
S’il est écrit avant, il pourra vous servir dès maintenant, à vous-même, pendant que la tâche est encore fraîche. Autrement dit, le feedback sera bien plus rapide et vous indiquera si la direction prise était la bonne ou pas.

Je me souviens d’un module à mon travail qui était particulièrement pénible à comprendre, y compris par mes pairs déjà présent dans l’entreprise depuis plusieurs années. À travers deux tâches que j’avais à faire sur ce module, j’ai tenté exactement les deux approches : écrire le test avant, et celui de le faire après. Sans surprise, mon retour d’expérience est indéniable : c’est plus intéressant en terme de feedback d’écrire le test avant.

Bonus: T comme Thorough

Lorsque vous recherchez le principe FIRST sur l’internet moderne, vous pouvez tomber sur une lettre différente pour le T. Bien que je n’ai pas pu retrouver la première source qui cite cette modification, je me permets de vous la donner puisqu’elle est très juste. Un test unitaire doit être complet.

La définition que j’apprécie le plus est celle citée sur le site de la toute première conférence internationale sur TDD.

The tests we write:

  • should cover all happy paths
  • should cover edge/corner/boundary cases
  • should cover negative test cases
  • should cover security and illegal issues

C’était l’une de mes erreurs sur ma vidéo citée plus haut. Un test doit couvrir tous les happy paths, notez le pluriel de la phrase. Un seul jeu de test ne sera sûrement pas suffisant pour établir que la fonctionnalité développée couvre vraiment ce qu’il est censé faire. Et si ce n’est pas le cas, cela augmente les chances que les tests cassent plus tard. D’expérience, je vois de plus en plus que cela devient un cauchemar pour beaucoup de maintenir des tests unitaires. En couvrant plus de cas, la fiabilité perçue s’agrandit.

Le test doit être propre

Uncle Bob le répète plusieurs fois dans son livre : les tests doivent être autant soignés que le code de production, voir, il doit être plus propre que ce dernier. Puisqu’un mauvais test unitaire est encore plus dévastateur qu’aucun test.
Une application qui rapporte de l’argent, c’est un code qui devra évoluer dans le temps. Cette phrase concerne tout aussi bien les tests. Or, si ceux-ci cassent pour des raisons obscures, c’est la défiance qui va s’installer et de plus en plus de développeurs iront jusqu’à dire que tester ne sert à rien. Dans cette situation, vous devriez prendre le choix de jeter ou maintenir le test qui échoue, ou arrêter d’en écrire. Dans tous ces cas, une perte de temps perçue.

La vraie question de départ devient : comment écrire de bons tests unitaires ?

What makes a clean test? Three things. Readability, readability, and readability.

La réponse souvent apportée est la pratique du Test Driven Development. Je vais totalement paraître contre courant en disant que ce dernier n’est probablement pas la solution la plus adéquat à votre entreprise.
En assistant à des conférences sur le sujet, tous imaginent qu’il suffit de dire aux développeurs « faîtes du TDD ! » pour que la qualité du produit augmente. À Agile Grenoble 2021 j’ai même assisté à une conférence qui disait que cette pratique était peu utilisée en entreprise, en s’appuyant sur une source scientifique. Pourtant, toutes les conférences sur le sujet font salle pleine systématiquement. TDD intéresse les entreprises.

TDD utilise les tests unitaires, mais si ces derniers sont mal écrits, ils ne servent à rien. Il y a non seulement tout une pratique qui nécessite un très long temps d’apprentissage, mais également l’architecture actuelle du code qui impact la testabilité de l’applicatif.

Le strict respect du principe FIRST est une partie de la réponse. Cela nécessite du temps et de l’investissement que votre entreprise ne pourra peut-être pas vous accorder. C’est la raison pour laquelle j’ai débuté ce blog et ma chaîne YouTube. Progresser par la pratique, le partage et les feedbacks.
Si c’est un sujet qui vous parle, et je crois que c’est le cas si vous avez lu jusqu’ici, je vous suggère de me suivre en vous abonnant à ces différents réseaux afin d’obtenir plus d’armes pour créer ou maintenir un logiciel de qualité. Un partage de votre part sera un excellent feedback.

Plus d’articles et de vidéos autour du développement sont prévus. À vous de voir.

D’autres références sur le principe

https://stackoverflow.com/questions/18024785/tdd-first-principle
https://github.com/tekguard/Principles-of-Unit-Testing
https://github.com/dotnet/docs/blob/main/docs/core/testing/unit-testing-best-practices.md
https://craftacademy.substack.com/p/5-principes-pour-ecrire-de-meilleurs
https://blog.devgenius.io/first-principles-of-unit-testing-5b6c452ccb7d

Comment

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *