Les qualités d’un bon test unitaire

Nous présentons ici quelques caractéristiques que tout bon test unitaire se devrait de présenter. Dans la pratique, et selon le contexte, il est difficile de cumuler toutes ces qualités. Il s’agit avant tout d’indications qu’il est important de garder à l’esprit quand on écrit un test unitaire.

Ciblé

En ciblant ce qui est testé, on s’astreint à :

  • ne tester qu’une fonctionnalité à la fois; si plusieurs fonctionnalités sont testées, alors le test peut certainement être décomposé;
  • tester seulement le code qui nous intéresse, et pas les composants qui sont autour – lorsque ces derniers interviennent, on considère qu’ils fonctionnent toujours parfaitement (au moyen de mocks si nécessaire);

Cette façon de procéder a tendance à orienter le design, en limitant notamment le couplage entre les composants, et est en parfait accord avec le Single Responsibility Principle.

Rapidité

La durée d’exécution d’un test unitaire ne devrait jamais excéder une poignée de milli-secondes, notamment si il doit être exécuté lors de l’intégration continue. En effet, des tests trop longs augmentent la durée de construction du build, retardant les développeurs dans leurs tâches quotidiennes et diminuant leur réactivité en cas d’échec dans les tests.

Lorsque l’exécution d’un test unitaire demande de faire preuve de patience, on peut se poser les questions suivantes :

  • s’agit-il vraiment d’un test unitaire ? dans le cas contraire, il peut être intéressant de le décomposer en aspects plus unitaires, et si ce n’est pas possible, d’exclure son exécution du  build;
  • son exécution fréquente est-elle cruciale pour assurer une bonne qualité du logiciel testé ?

En bref, on peut essayer d’alléger la durée d’exécution du test, et à défaut l’éliminer de l’intégration continue. L’idée reste de ne pas alourdir la durée de construction du build.

Reproductibilité

A code constant, le résultat d’un test unitaire doit toujours être strictement identique. Formulé dans l’autre sens, un changement de comportement du test unitaire ne devrait résultat que d’une modification dans la base de code, c’est-à-dire intrinsèque au code testé.

Le résultat d’un test unitaire ne doit donc pas dépendre de conditions extérieures au code testé, en particulier si ces conditions ne sont pas fiables à 100%. Les éléments suivants sont donc à éviter :

  • connexion à un serveur, l’absence de réponse de ce dernier entraînant un échec du test;
  • accès à des fichiers par un chemin absolu (encore pire si il s’agit d’un répertoire sur le poste du développeur), il est préférable ajouter ces fichiers au gestionnaire de sources;

Il existe néanmoins un cas particulier à l’usage de ces éléments, qui consiste à tester la gestion d’erreurs en leur absence. Dans ce cas, le test simule l’absence systématique, par exemple, de la connexion réseau, ce qui le rend reproductible.

Parfois, l’accès à des ressources extérieures fait partie de l’architecture testée. Dans ce cas, on essaiera de mocker ces éléments, en simulant leur comportement attendu. Mais dans le cas d’un code legacy, cela peut devenir un exercice difficile.

La reproductibilité du test unitaire est importante dans la mesure où il est voué à être exécuté par le processus d’intégration continue, et que l’échec inopiné d’un test peut entraîner l’échec du build. En plus de pénaliser l’ensemble des développeurs, la qualification de la cause d’échec est plus difficile lorsqu’elle n’est pas propre à un commit récent.

Pédagogie

Dans la mesure du possible, le test unitaire doit guider l’utilisation du composant testé, en fournissant dans une certaine mesure une forme de documentation ou de spécification.

En premier lieu, l’objectif du test doit apparaître clairement, sans ambiguïté sur la fonctionnalité testée. On évite aussi d’introduire de façon cachée le test d’autres fonctionnalités (il est préférable d’écrire d’autres cas de tests, même si le déroulement est similaire, ou à défaut de documenter qu’on teste plusieurs choses). Le nommage des tests façon Behavior Driven Development constitue un bon moyen d’indiquer la finalité du test.

Une décomposition claire du scénario de test est également importante, on essaye de distinguer les différentes phases :

  • déclaration préalable des résultats attendus, si ceux-ci sont complexes
  • initialisation du composant testé
  • programmation du comportement des mocks, si nécessaire
  • exécution du composant testé
  • capture des valeurs de retour
  • assertions sur les valeurs de retour permettant de valider le succès ou l’échec du test

Enfin, il est important de documenter autant que possible les causes d’échec du test. Un moyen peu coûteux d’y parvenir consiste à fournir des messages explicites dans les méthodes d’assertion. L’objectif est de faciliter la qualification du problème en cas d’échec d’un test.

Laisser un commentaire