Workflow TDD – Partie 1

Une commande de test tout‑en‑un pour Elixir

4 décembre 2023 ⋅ #TDD #Elixir

Si comme moi vous pratiquez le test driven development, vous lancez logiquement vos tests très souvent. Plusieurs centaines de fois par jour même. Or sur certains projets, l'exécution des tests peut prendre un certain temps. Il est donc important d'optimiser cela très tôt dans un projet.

Je présente ici quelques pistes pour faire cela.

Cet article concerne principalement le langage Elixir, dont le framework de test, ExUnit, est un petit bijou. Les principes décrits ici sont applicables à d'autres langages ou frameworks mais cela demandera plus de travail.

Lancer uniquement les tests pour le code modifié

Si votre langage ou framework le permet, la première chose à faire est de trouver comment lancer uniquement les tests modifiés depuis le dernier lancement des tests.

La commande mix test --stale est assez magique.

Elle permet de ne lancer que les tests qui touchent du code modifié. Si, après avoir lancé les tests, vous modifiez uniquement un module A, alors seuls les tests exécutant du code de ce module seront lancés.

Le tracking est bien sûr transitif, si le module A appelle du code de B et que vous avez modifé B alors les tests pour A seront lancés, comme les tests pour le module B.

Si votre code est bien découplé, l'exécution des tests sera très courte.

Et bien sûr, si vous modifiez un test, il sera relancé également.

Répéter les tests échoués

Le fameux cycle red-green-refactor implique qu'au moins un test va échouer pour trois exécutions de la suite de tests. En théorie du moins. En pratique ce sera bien plus souvent.

Il serait donc intéressant de pouvoir relancer uniquement le test qui a échoué. La command mix test --failed permet de faire cela.

Mon workflow est donc le suivant :

  1. Écrire un test (ou une partie d'un test).
  2. Exécuter mix test --stale.
  3. Le test est red, implémenter la solution.
  4. Exécuter mix test --failed et modifier l'implémentation plusieurs fois jusqu'à ce que le test soit green.
  5. Refactorer et exécuter mix test --stale jusqu'à ce qu'on soit satisfait du code et que le test soit toujours green. Si un test est red (cela peut être un autre test), nous nous sommes en fait téléportés au point 4 ci-dessus.

Ça reste assez simple et intuitif : --stale pour lancer les tests quand ils sont green et --failed pour lancer les tests quand ils sont red.

Nous allons encore simplifier tout ça. Voici notre nouvelle commande de test:

mix test --failed && mix test --stale

Seulement, il reste un problème.

N'exécuter qu'un seul test à la fois

Au point 5, il se peut qu'une de nos modifications ait cassé plusieurs autres tests. Des dizaines même. Il faut donc les corriger un par un.

La première chose à ajouter à notre commande est l'option --max-failures 1 qui permet de s'arrêter dès la première erreur, afin de ne pas avoir un ouput en console illisible, et de savoir rapidement quoi corriger.

Cela donne ça:

mix test --failed --max-failures 1 && \
mix test --stale

Malheureusement, cela ne suffit pas. Si plusieurs tests échouent, la commande mix test --failed --max-failures 1 va s'arrêter sur n'importe quel test.

La raison est que par défaut, mix test lance tous les tests dans un ordre aléatoire.

  • Les modules de tests sont lancés dans un ordre aléatoire.
  • Chaque test au sein d'un même module est lui aussi lancé dans un ordre aléatoire.
  • La seule constante qui reigne est que tous les tests d'un même module sont exécutés avant de passer au module suivant. (J'omet volontairement les tests :async qui ne compliquent pas vraiment le problème.)

Il existe une solution mais les choses vont se compliquer un peu.

Répéter uniquement le même test

Nous aimerions lancer le même test encore et encore, jusqu'à ce qu'il soit green, avant de passer au test suivant.

Pour remédier à cela, il est possible de passer un integer à l'option --seed de mix test, qui permet de fixer la graine du générateur aléatoire et de présever l'ordre des tests :

mix test --failed --max-failures 1 --seed 1234 && \
mix test --stale --seed 1234

La seed étant fixe, tous nos tests s'exécuteront toujours dans le même ordre. Mais il y a une raison pour laquelle les tests sont lancés dans un ordre aléatoire ! Cela permet de détecter de mauvais tests qui fonctionnent uniquement parce qu'un autre test à modifié l'état de l'application et a créé une certaine condition. Si cet autre test n'est pas exécuté avant, ces mauvais tests ne fonctionneront plus.

Pour garder cette fonctionnalité, nous allons donc modifier la commande afin de générer une nouvelle seed après chaque exécution complète de la commande. Tant qu'au moins un test est red on garde la même seed, et quand tout est green on change de seed.

Il y a plusieurs façons de faire ça, mais voici une méthode facile. La plupart des shells sur Linux (et probablement MacOS) définissent la variable $RANDOM qui contient un nombre aléatoire entre 0 et 32767.

Exécutez ceci et vous aurez trois nombres différents :

echo $RANDOM
echo $RANDOM
echo $RANDOM

Pour sauvegarder notre seed et la réutiliser plusieurs fois, nous alons l'écrire dans un fichier. Nous le plaçons dans le dossier _build qui contient tous les artifacts de compilation et de test.

Nous devons également gérer le cas où le fichier n'existe pas encore. Dans ce cas nous utilisons une seed par défaut.

Voici donc notre nouvelle commande:

mix test --failed --max-failures 1 --seed $(cat _build/seed || echo 0) && \
mix test --stale --seed $(cat _build/seed || echo 0) && \
echo $RANDOM > _build/seed

Lors de la première exécution, le fichier seed n'existant pas, cat affichera un message d'erreur que nous pouvons ignorer, ou supprimer en exécutant:

touch _build/seed

Conclusion

Pour le moment, cette commande est un peu longue à taper, on voudra en général la stocker quelque part. Je présente une solution dans la seconde partie.

Mais elle permet de prioriser l'exécution des tests, de rester concentré sur un seul problème à la fois et de ne pas s'éparpiller.

Je vous incite donc à l'essayer et bien sûr de la personnaliser à votre convenance.

J'aimerais aussi vous parler de odo qui est un simple programme écrit en C permettant tout simplement d'incrémenter un nombre dans un fichier. Je l'utilise simplement pour aérer un peu ma commande :

mix test --failed --max-failures 1 --seed $(odo -c _build/seed.txt) && \
mix test --stale --seed $(odo -c _build/seed.txt) && \
odo _build/seed.txt

Pour l'installer, il faut cloner et le compiler, et supprimer le fichier seed existant. En soi rien de bien compliqué mais j'ai préféré présenter la solution avec $RANDOM qui n'a pas de dépendance et est immédiatement disponible.

Bons tests !