Comprenez les tests unitaires

Qu’est-ce qu’un test unitaire ?

Nous avons tous des phrases ou des citations gravées à jamais dans nos mémoires, mais dont on ne se souvient pas de la source. Voici l’une de mes préférées :

Si tu veux 5 définitions différentes de ‘test unitaire’, demande à 3 développeurs.

Ça ne nous aide pas beaucoup, donc je vais paraphraser la définition de Vladimir Khorikov dans Unit Testing: Principles, Practices, and Patterns. Un test unitaire est un test automatisé qui :

  • vérifie un petit segment de code (unité),

  • rapidement,

  • de manière isolée.

Les débats sont éternels sur les détails de ces trois points. Qu’est-ce qu’une unité ? Rapide comment ? Isolé à quel point ?

Pour ce cours, les réponses sont les suivantes :

  • l’unité est comportementale : on n’écrit pas un test pour chaque méthode de chaque classe, mais un test pour chaque comportement (ex : tester que lorsque l’utilisateur clique sur « Trier par date », la liste affichée est bien triée — on n’appellera pas individuellement les différentes méthodes de cette action)

  • chaque test prend moins de 5 minutes, voire encore moins

  • on écrira des tests sociaux en suivant l’école classicist — on utilisera un strict minimum de test double pour s’isoler des dépendances externes (API, libs tierces etc). L'alternative serait l'école mockist : dans les chapitres suivants, j'expliquerai pourquoi je préfère ce choix

Pour plus d’informations sur ces différents points, voici quelques ouvrages que je vous recommande vivement :

  • Unit Testing: Principles, Practices, and Patterns de Vladimir Khorikov

  • Test Driven Development: By Example de Kent Beck

  • The Art of Unit Testing de Roy Osherove

  • Working Effectively With Legacy Code de Michael Feathers

  • Test Desiderata, article de Kent Beck

À quoi ça sert ?

Dans son livre Working Effectively with Legacy Code, Michael Feathers définit le code legacy comme tout code qui n’a pas de tests. Vous savez, ce code que personne ne veut toucher par peur de casser quelque chose, et oubliez totalement tout souhait de refactorisation !

Les tests vous libèrent de ça, en particulier les tests unitaires. Non seulement vous pouvez apporter des ajouts et des modifications de comportement beaucoup plus facilement, vous pouvez aussi refactoriser sans régression, vous permettant d’améliorer sans cesse la structure de votre code.

En plus, je trouve qu’il y a deux autres avantages des tests unitaires qui sont à souligner :

  • ils documentent votre code : puisque vos tests couvrent et décrivent des comportements, vous n’avez jamais à vous poser la question « à quoi sert ce bout de code ? » — ce sont des specs exécutables

  • ils forcent votre code à être "testable" : globalement, votre code sera mieux structuré et plus modulaire

Ce dernier point est très important : si vous vous retrouvez un test qui est très difficile, voire impossible à écrire du fait de la structure de votre code, c’est un signal qu’il faudrait peut-être revoir votre architecture.

Oui mais…

  • "J'ai pas le temps d'écrire des tests"

    • Une fois que vous saurez bien écrire vos tests, vous irez plus vite avec que sans : vous n'aurez plus à tout tester systématiquement à la main à chaque fois que vous apportez une modification ou effectuez une refactorisation

  • "Mon code est tellement simple que ça ne vaut pas la peine de le tester"

    • Même si ça peut parfois être le cas, combien de "bouts de code simple" sont ensuite devenus des Béhémoths ingérables par la suite

  • "J'écrirai les tests plus tard, là il faut avancer"

    • J'ai beaucoup entendu cette phrase dans ma carrière de consultant, et 6 mois ou un an plus tard, le code en question n'avait toujours pas de tests : écrivez vos tests pendant que vous avez le contexte et les comportements en tête, car plus tard ce sera trop tard

Qu'est-ce qu'on teste ?

Comme je l’ai dit plus haut, ce qui nous intéresse ce sont les comportements de notre application et non les détails d’implémentation. Un comportement est le quoi de l'application et non le comment : concrètement, on veut tester les résultats observables de notre code sans en tester le fonctionnement interne.

Dans une application Angular, un seul comportement peut traverser plusieurs objets : un ou plusieurs components, services, directives, pipes… les tester ensemble pour en valider le comportement permet entre autres de refactoriser plus tard : aujourd'hui, vous avez peut-être un component qui appelle un service, mais si demain vous voulez ajouter des sous-components et des directives mais que le comportement ne change pas, vous ne voulez pas avoir à modifier votre test !

Voici quelques catégories de comportement dans une application Angular, avec quelques exemples :

Interactions et input utilisateur

  • lorsqu'un utilisateur entre une adresse mail invalide, un message d'erreur apparaît

  • lorsqu'un utilisateur clique sur une table, la table apparaît comme sélectionnée

  • lorsqu'un utilisateur remplit le formulaire de réservation et le soumet, les données de sa réservation sont envoyées au serveur

Affichage et état

  • pendant que l'utilisateur attend que la carte s'affiche, un spinner est affiché

  • si une table n'a plus de places disponibles, un badge "Complet" s'affiche

  • si une commande est passée entre il y a 31 minutes et il y a 90 minutes, le texte "il y a une heure" y est affiché

Gestion d'erreur

  • si un utilisateur essaye de réserver une table alors que toutes les tables sont prises, il reçoit une proposition d'horaire où une table est disponible

  • si la carte ne peut pas être chargée, un bouton "Réessayer" s'affiche

Accessibilité

  • si la soumission du formulaire échoue, le premier champ invalide doit être focus

  • le focus doit avancer au prochain champ si l'utilisateur appuie sur Tab

  • si une table devient disponible, ce changement doit être annoncé par les lecteurs d'écran

Qu'est-ce qu'on ne teste pas ?

Il m'est arrivé assez souvent d'être appelé sur des projets où les développeurs ne veulent plus écrire de tests : ils y passent trop de temps, ça ne marche pas, ça casse tout le temps, ou ils n'y voient pas l'intérêt. Dans 99% des cas, c'est parce qu'ils testent des…

Détails d'implémentation

Un détail d'implémentation est un choix fait par les développeurs pour implémenter un comportement :

  • les méthodes privées/internes de vos components, services etc.

  • l'état interne de vos objets (variables privées)

  • l'ordre d'appels de méthodes

  • les différents objets traversés

Tester ces choses-là couple vos tests à l'implémentation de votre code, et vous forcera à les changer si vous refactorisez. Je le répète : si le comportement ne change pas, le test ne doit pas avoir à changer.

Code tiers

Vous voulez tester votre code à vous, pas le code des autres ! Évitez de tester le comportement d'Angular lui-même ("est-ce qu'on passe bien par ngOnInit?") ou des librairies que vous utilisez ("est-ce que quand l'utilisateur entre une valeur dans un input Material, mon formulaire reçoit la valeur ?").

Red flags

Attention à ces signaux d'alerte !

  • un test qui casse lors de la refactorisation — très souvent, le test est trop couplé à l'implémentation (si vous changez le design de l'objet testé, cependant, il est normal que cela casse des tests)

  • un test qui est très difficile à écrire — repensez votre architecture pour bien séparer les responsabilités

À vous de jouer

Contexte

Avant de vous lancer dans la pratique, prenez un peu de temps pour réfléchir aux principes des tests unitaires et surtout à la différence entre comportements et détails d'implémentation.

Consigne

  1. Choisissez un projet sur lequel vous avez travaillé récemment, si possible un projet frontend (sinon ce n'est pas grave, les principes sont les mêmes).

  2. Identifiez 5 comportements de votre application.

  3. Pour chaque comportement, rédigez une phrase courte qui commence par "Lorsque…" ou "Si…" qui décrit le comportement sans mentionner les détails d'implémentation.
    Ex: "Lorsque l'utilisateur supprime un article, on lui demande de confirmer son souhait avant de le supprimer."

L'objectif de cet exercice est de vous aider à commencer à réfléchir par comportement et non par détail technique.

En résumé

  • les tests unitaires sont des tests automatisés qui vérifient des unités de comportement rapidement et de manière isolée

  • les bénéfices principaux d'une suite robuste de tests unitaires sont la confiance pour la refactorisation et la modification, et une documentation exécutable

  • on teste des comportements et non des détails d'implémentation

  • dans les applications Angular, on testera des scénarios comme la validation de l'input utilisateur, des changements de UI, de la gestion d'erreur…

Tout ça fait beaucoup de théorie, mais c'est vraiment très important d'avoir ces éléments en tête pour ne pas tomber dans les pièges des mauvais tests. Dès le prochain chapitre, vous écrirez vos premiers vrais tests sur l'application TableMaster et vous verrez comment on applique ces différents concepts.

Et si vous obteniez un diplôme OpenClassrooms ?
  • Formations jusqu’à 100 % financées
  • Date de début flexible
  • Projets professionnalisants
  • Mentorat individuel
Trouvez la formation et le financement faits pour vous