5 principes pour écrire de meilleurs tests unitaires à mettre en place dès maintenant
Comment un simple acronyme peut vous aider à écrire de meilleurs tests
Cet article fait partie d’un groupe d’articles à propos des tests automatisés. Si vous avez manqué le précédent article, je vous invite à le lire ici : 🔴 Vos tests unitaires échouent dès que vous bougez le petit doigt...
Écrire de meilleurs tests unitaires grâce aux principes F.I.R.S.T
On aime bien les acronymes dans le monde du développement. Il y en a à la pelle ! Certains, comme celui que nous allons voir aujourd’hui, peuvent nous être très utiles en ce qui concerne l’écriture de tests automatisés.
Voyons donc l’acronyme F.I.R.S.T.
F comme Fast 🚀
Un test unitaire doit être rapide à s’exécuter. L’avantage de ces tests est qu’ils apportent un feedback qui se veut être le plus court possible entre le moment où l’on écrit le test et le moment où l’on écrit le code qui va faire passer ce test. En ce sens, si le test est long à s’exécuter, ce feedback sera plus long aussi, ce qui entraînera une perte de productivité.
L’intérêt est aussi d’avoir une suite complète de tests unitaires qui peut s’exécuter en à peine quelques secondes ! Ces tests assurant la vérification des différents comportements attendus de notre application, il est primordial qu’ils puissent s’exécuter le plus souvent possible afin d’être sûr qu’aucune régression ne s’est glissée.
Des tests unitaires qui s’exécutent trop lentement pousse le développeur à ne plus les exécuter, car ils le ralentissent. Voire à ne plus les écrire du tout. Dans les deux cas, on a perdu totalement l’intérêt des tests unitaires.
Faites le test avec votre code, est-ce que vos tests sont rapides à exécuter ? Est-ce que vous êtes satisfaits de la rapidité du feedback qu’ils vous apportent ?
I comme Independent 🧪
Chaque test unitaire doit pouvoir s’exécuter indépendamment des autres. Qu’est-ce que cela signifie concrètement ? Qu’il ne faut pas que vos tests partagent des données. Ils doivent tous pouvoir s’exécuter en parallèle et contenir dans leur contexte toutes les informations nécessaires à sa compréhension, et à la compréhension de la raison de l’échec du test. Cela est possible uniquement si les tests unitaires sont indépendants entre eux, mais aussi vis-à-vis de leurs dépendances. On utilisera pour ce faire des “tests doubles”, ou “doublure” qui permettront de “remplacer” les vraies dépendances par du code que l’on peut instancier directement dans le test.
Imaginez que vous deviez tester que lorsque vous ajouter un produit dans un panier alors le produit s’y trouve bien, et que vous souhaitiez faire un second test unitaire pour vérifier que ce produit peut être retiré du panier. Vous pourriez être tenté de vous dire :
“Je vais faire le premier test unitaire qui vérifie que le produit est ajouté dans le panier”
"Maintenant que le produit est ajouté dans le panier, mon second test va pouvoir vérifier qu’on peut l’en retirer”
Ce faisant, les deux tests sont maintenant couplés, car ils partagent un état. Ils ne peuvent plus être exécuté dans n’importe quel ordre, ou en parallèle.
Dans cet exemple, il est simple de voir qu’il existe un état partagé, mais cela peut parfois être plus compliqué à détecter. Notamment si vous utilisez des “mocks” par exemple. Quelques indices qui peuvent vous mettre sur la piste de tests unitaires non indépendants :
Votre test unitaire passe parfois alors qu’à un autre moment, il échoue, sans que vous ayez modifié de code.
Lorsque vous exécutez votre test unitaire tout seul il passe, mais dès qu’il est exécuté au sein de la suite de test complète, il échoue.
R comme Repeatable 🔁
Ce principe est le corollaire du précédent. Il stipule qu’un test unitaire doit toujours donner le même résultat, peu importe le nombre de fois où il est exécuté, ni sur quel environnement il est lancé.
Il arrive que l’on ait l’impression que nos tests sont répétables à l’infini, parce que ceux-ci se sont toujours comportés comme attendu. Cependant, il existe des pièges qu’il faut éviter, et qui se cache souvent dans ce que l’on appelle des dépendances implicites.
Les dépendances implicites que l’on retrouve le plus souvent sont :
l’utilisation de l’aléatoire (génération d’un nombre aléatoire par exemple)
l’utilisation de la date du jour
Lorsque ces éléments sont présents dans une fonction testée, votre couverture de tests peut très bien vous indiquer 100% alors même que vous n’êtes pas à l’abri que ces tests échouent à un moment donné ! Soit parce que le nombre aléatoire qui avait une chance sur 10000 d’être généré a finalement été généré et que vous n’aviez pas prévu ce cas à la marge, soit parce que vous faites des comparatifs par rapport à la date du jour et une date antérieure qui finira par dépasser là encore les limites que vous aviez prévues. Ce sont des dépendances implicites. Votre fonction dépend de la génération d’un nombre aléatoire. Votre fonction dépend de la date du jour.
Pour remédier à cela il suffit de rendre ces dépendances explicites. C’est-à-dire, pouvoir contrôler la génération de nombres aléatoires depuis les tests, pour directement simuler que le nombre tiré au hasard sera celui que l’on choisi dans le cadre de notre test. Ou pouvoir contrôler la date du jour, pouvoir dire dans notre test “aujourd’hui nous sommes le …” plutôt que de reposer sur le système pour récupérer la date réelle du jour.
S comme Self-validating
Pourquoi est-ce que l’on s’embête avec des frameworks de test comme Jest par exemple ?
Après tout, une simple fonction qui ferait un console.log(‘
🟢‘)
si le test passe ou un console.log(‘
🔴‘)
si le test échoue pourrait suffire !
La notion de “auto-validation” signifie que le développeur ne doit pas avoir à faire manuellement de vérification pour comprendre que le test a échoué ou qu’il a réussi. En l’occurrence ici il faudrait regarder la console pour comprendre si le test a échoué ou non et l’ordinateur n’aurait aucun moyen de le savoir directement.
Ces frameworks de tests permettent l’automatisation de le l’exécution de ces tests, car ils nous permettent d’une part d’avoir des assertions claires, et d’autre part signifier à “l’ordinateur” que nos tests ont échoué, ou sont passés.
C’est le rôle de chaque test de savoir si le comportement attendu est le bon ou non.
T comme Thorough
Un test “thorough” est un test qui doit être “complet”. C’est-à-dire qu’il doit tester tous les cas d’utilisation possible du comportement testé.
Il faut donc tester le “happy path”, quand tout se passe comme prévu, mais aussi tester les “sad paths”, ceux où une erreur doit être levée par exemple.
Il convient de tester aussi les cas limites, ceux où l’on se dit “de toute façon ça n’a franchement que très peu de chance d’arriver, pas besoin de le tester”.
Si le comportement est “théoriquement” possible, alors il doit être testé pour s’assurer qu’on y répond de la bonne façon et que l’on ne laisse pas de “trous dans la raquette” dans notre code.
Ici encore, les outils de couverture de code peuvent induire en erreur en indiquant une couverture de 100% du code, alors que certains cas à la marge ne sont pas testés.
On préfèrera utiliser ici d’autres outils, comme le “mutation testing” qui permet de vérifier que nos tests échoue si l’on en modifie les prédicats, indiquant ainsi qu’aucun cas à la marge n’a été oublié. Mais j’en reparlerai plus tard :)
Là encore il existe un acronyme pour nous aider à tester tous les comportements possibles, mais ce sera le sujet de l’article de la semaine prochaine !
Un petit indice tout de même : 🧟
Happy Coding :)
Cet article vous a plu ? Vous pouvez me soutenir gratuitement en vous inscrivant à mon programme de parrainage, en échange, je vous offre des bons de réduction valables à vie sur tous les cours (actuels et futurs) de CraftAcademy.fr 🎉