Comment écrire des Tests Unitaires de qualité mais surtout, utiles !

Nicolas Poste
8 min readJun 10, 2019

Vous êtes un·e développeur·se, Lead Dev/Tech, CTO et cherchez à améliorer l’efficacité des tests ? Je consacre cet article à l’écriture des tests unitaires et ses bonnes pratiques.

🖼️Tout au long de cet article, j’utiliserai une image qui associe les tests unitaires à des globules blancs d’un organisme vivant, première ligne de défense contre les maladies. Les tests unitaires pourraient être vus comme des globules blancs, qui, en surnombre et bien entraînés, sont très redoutablement efficaces (et pas chers !) contre les bugs de tous les jours. Ils ne suffisent toutefois pas car ces globules blancs sont entraînés pour n’éliminer que les comportements anormaux qu’on leur a appris à détecter.

📝 J’avais d’ailleurs déjà utilisé une image dans le lexique de la santé lors d’un précédent article : Allo docteur, je crois que mon code est malade

Rappels

Comme je le mentionne dans un précédent article (Les tests, oui, mais pas n’importe comment !), les tests unitaires doivent suivre les principes FIRST

  • [F]ast, rapides, quelques milli-secondes maximum
  • [I]solated, isolés, aucun test ne dépend d’un autre, pour qu’une collection de tests puisse être jouée dans n’importe quel ordre
  • [R]epeatable, répétables, joués N fois, produisent toujours le même résultat
  • [S]elf-validating, auto-validés, chaque test doit être capable de déterminer si son résultat est celui attendu ou non. Il doit déterminer s’il a réussi ou échoué. Il ne doit pas y avoir d’interprétation manuelle des résultats
  • [T]imely, opportuns, ils doivent être écrits à peu près en même temps que le code qu’ils testent. Le TDD les écrit même avant

⚠️ Trop souvent, “isolé” est mal compris. Cela ne signifie en aucun cas qu’un test ne doit tester qu’une seule classe ! Michaël Azerhad y consacre d’ailleurs tout un article avec exemples que vous pourrez trouver dans les sources et références (1)

Différents types de tests unitaires

Comme Jessica Mauerhan l’écrit dans sa présentation (2), il existe trois types de tests unitaires :

  • exactitude : on vérifie qu’un cas simple retourne ce qu’il est censé retourner (et même les exceptions). On y utilise des assertions.
  • contrat : Martin Fowler l’aborde dans l’un de ses articles (3). On y utilise également des assertions.
  • collaboration : sa responsabilité est d’orchestrer l’invocation d’autres unités. On y utilise des attentes.

Chaque type de test peut faire intervenir un ou plusieurs Test Doubles. Il en existe cinq types :

  • dummy : objet demandé par l’API mais qui n’est pas utilisé. On pourrait d’ailleurs jeter une exception si l’une de ses méthodes était appelée pour s’assurer que le dummy ne doive jamais être utilisé !
  • stub : on implémente une classe qui va répondre exactement ce qu’on attend d’elle
  • fake : il s’agit d’une sorte de mini-implémentation de la vraie classe. On peut utiliser les fakes pour simuler une base de données par exemple
  • mock : objet pré-câblé qui permet de donner une réponse quand on le sollicite de façon pré-définie
  • spy : objet qui permet de vérifier la sortie indirecte d’un code testé, via des assertions définies avant que le code testé ne soit exécuté. Il permet notamment d’enregistrer des informations sur l’objet indirect créé, tel que le nombre de fois qu’une méthode est appelée

On utilise souvent à tort le terme “mock”. Benoît Sautel, Mock ou pas Mock (4) y consacre tout un article.

Vous pourrez trouver des codes d’exemple dans le blog de KNP, Mocks, Fakes, Stubs, Dummy et Spy — Faire la différence (5).

🖼️ D’ailleurs, il existe également plusieurs types de globules blancs qui ont leurs propres méthodes de détection et d’élimination des intrus. Que la nature est belle !

Lesquels utiliser et quand ?

Dans les tests d’exactitude et tests de contrat, les Test Doubles les plus rencontrés et recommandés sont les dummies, stubs et fakes.

Dans les tests de collaboration, les Test Doubles à utiliser sont les mocks et spies.

Pour un type de test donné, il doit être rare voire déconseillé d’utiliser d’autres Test Doubles autres que ceux préconisés, cela sera dans la majorité des cas une mauvaise pratique.

Exemple : des mocks dans un test d’exactitude ? A éviter !

État des lieux

Malgré un nombre d’articles assez conséquents sur le sujet, il est assez fréquent de tomber sur des tests unitaires mal implémentés ou sur des méthodes de tests peu performantes.

Pourtant, ce sont bien les tests unitaires qui constituent la toute première défense contre les vilains bugs. Et, un peu comme un organisme, toute brèche est exploitable et il est alors bon de s’assurer d’en couvrir le maximum.

Défauts rencontrés

Miroir miroir, dis moi qui est sans défaut

Le défaut que je rencontre le plus souvent, est d’écrire son test unitaire comme un miroir du code de production.

Une classe de production => une classe de tests

Pour (presque) chaque méthode => une méthode de test

Ce choix a de nombreuses conséquences :

  • tout changement d’implémentation sera coûteux car il faudra réécrire les tests, les adapter
  • seul le contenu des classes est testé, on ne transite pas par d’autres classes qui pourtant sont très pertinentes et font déjà tout le travail. Le passage d’une classe à l’autre n’est pas testé tel qu’il le serait en production.

Mock mock, qui est là ? Un mock

Le second défaut est d’utiliser des mocks à outrance. Ce point relève du code smell pour de nombreuses personnes, dont Eric Elliott (cf Mocking is a code smell (6)) et bien d’autres, dont moi. Souvent, cela est lié au premier défaut cité ci-dessus : on mock toutes les autres classes autres que celles qui est testée.

💡 Dans ce genre de cas, la méthode la plus simple pour ne pas mocker à outrance est de ne pas écrire ses tests comme un miroir du code de production. Il n’y aura alors plus de tendance à mocker toutes les autres classes. Pour cela, commencez par écrire une classe de test qui correspond à votre fonctionnalité, ajoutez-y une méthode qui teste l’un des cas, complétez l’implémentation, refactorez, puis ajoutez un autre cas, …

Le problème avec les mocks utilisés en dehors des tests de collaboration, c’est qu’ils sont souvent trop proches des implémentations. Changer l’implémentation se traduit alors généralement par repasser par le test.

Pourquoi prendre du temps à créer des mocks à outrance alors que l’implémentation de production fait déjà très bien le travail ? Plutôt que de faire en sorte qu’une méthode (ne faisant appel à aucun partenaire, ce qui est valable pour tous les tests à l’exclusion des tests de collaboration) retourne un objet construit par vos soins, appelez l’implémentation de production qui est déjà censée retourner ça pour vous !

Couvre toi de la tête aux pieds

Le troisième problème que je vois est issu d’un objectif souvent donné par la hiérarchie : avoir une couverture de code qui atteint un certain pourcentage. La mauvaise décision prise est souvent de gonfler, de façon plus ou moins artificielle, la couverture par des tests qui auront pour objectif de parcourir le plus de lignes de code.

Un des effets indésirables est d’augmenter le coût du développement sans avoir de retour sur investissement suffisant.

Il faut revoir l’approche : le principal, c’est de couvrir les fonctionnalités, pas le code !

📝 Des éditeurs de logiciels (et je pense de plus en plus), choisissent volontairement de ne plus afficher le taux de couverture de code. En effet, cet indicateur n’est pas le plus pertinent et entraîne trop souvent un mauvais focus. Sans que ce soit un objectif recherché, mettre en place le TDD suffit généralement à avoir une excellente couverture de code.

💡 Malheureusement vu et vécu… produits par des développeurs de grosses ESN, des tests sur des getters/setters d’un POJO anémique, des tests sans aucune assertion, …

Mais alors, comment écrire un test unitaire de qualité ?

Commencer par ne pas reproduire les mauvaises pratiques décrites ci-dessus est une excellente idée :)

La mise en place du TDD/BDD aide à construire des tests de qualité. Les tests écrits en respectant ces principes sont non seulement de qualité, mais également utiles ! Ils ne font pas que vérifier que le code fonctionne, ils aident également les développeurs à écrire un code bien construit et bien découpé, testable facilement sans hacks et contournements. C’est, je pense, la meilleure façon de développer dans de bonnes conditions.

📝 “Le TDD fait correctement est le BDD. Le BDD est le TDD réalisé correctement.”

D’accord, mais que tester ?

Lors de la création d’un nouveau test, il est préférable de se baser sur le comportement, tester les entrées/sorties, sans entrer dans l’implémentation. La conférence TDD, Where Did It All Go Wrong, et plus particulièrement le passage Avoid Testing Implementation Details, Test Behaviours (7), de Ian Cooper est un excellent support pour cela.

Ainsi, il est préférable de tester l’API publique de l’unité. On parle de “black box testing”, à opposer au “white box testing”.

Tandis que le black box testing se contente de tester les entrées/sorties, le white box testing est fortement couplé aux détails d’implémentation, ce qui a de fortes probabilités de casser le test, augmentant par conséquent le coût lors de changements de l’implémentation. En somme, le white box testing amène à du précieux temps gâché, au détriment de la production de valeur.

💡 On notera au passage que le TDD met naturellement l’action sur les tests de type black box, puisque l’implémentation n’existe pas au moment de la rédaction du test

Test, je te nomme…

Pour ce qui est du nommage des classes et méthodes de test, j’aime bien l’approche de Sandro Mancuso, décrite dans son article Naming Test Classes and Methods (8).

Exemple :

Dans cet exemple, on retrouve le formalisme Given When Then, que l’on retrouve dans le BDD, comme l’écrivent Martin Fowler dans GivenWhenThen (9) et Bill Wake dans 3A — Arrange, Act, Assert (10)

Lorsqu’on joue les tests unitaires avec l’IDE, on bénéficie alors d’un affichage lisible :
MyFunctionalityShould
- do_one_usecase
- …

Je groupe mes tests

Avec JUnit 5, il est possible d’utiliser l’annotation @Nested, qui permet de regrouper différents tests sous une classe.

💡 Sans avoir JUnit 5, il existe une librairie semblable qui permet de regrouper des tests : junit-hierarchicalcontextrunner de Bechte

En bonus, grouper ses tests peut aider à mettre en avant le comportement.

Un nouveau bug arrive malgré tout ?

Pas de panique ! Lorsqu’un nouveau bug est détecté (et cela va très certainement arriver), il faudra alors, en plus de corriger le bug, enrichir la base de tests afin d’éviter tout bug similaire.

🖼️ Il faudra alors apprendre à ces globules blancs à se défendre contre de futurs bugs aux apparences proches. C’est ce qui est fait avec le vaccin : on injecte quelques anomalies en sous-nombre pour entraîner l’organisme à se défendre.

Et après ?

Bien que très performants, les tests unitaires ne suffisent certaines fois pas. D’autres types de tests doivent être mis en place, tels que les tests d’intégration ou les tests de bout en bout.

💡 Les tests de bout en bout pourraient s’imaginer en tant qu’électro-cardiogramme. Nécessaires pour visualiser rapidement si l’état général va bien, mais chers et longs à s’exécuter.

Et vous ?

Je serais heureux d’avoir vos avis sur la matière, d’échanger, … alors n’hésitez pas à me contacter ou à laisser des commentaires !

Sources et références citées ou liées :

--

--

Nicolas Poste

CTO @ Ceetiz, passionné par le Software Craftsmanship, les aspects techniques, d'automatisation pour gagner en efficacité, Docker, ...