Fleche retour aux articles de blogBien démarrer avec les tests unitaires en JavaScript

Mots clés :

développement web

Le contrôle du code

Le travail d'un développeur consiste essentiellement à écrire du code qui met en œuvre des fonctionnalités et répond à des exigences commerciales. Cependant, un aspect souvent laissé de côté concerne le contrôle du code - contrôle qui est nécessaire pour s'assurer qu'il fonctionne comme prévu et qu'il continuera à le faire au fur et à mesure que le programme évolue.

Ce comportement, parfois un peu trop complaisant, face à la (non) rédaction de tests est souvent attribué au manque de temps. « Le temps dédié aux tests, c’est du temps qui n’est pas dédié à la création de fonctionnalités ! » est une phrase que l'on peut entendre régulièrement. Ainsi, trop souvent, cette tâche redoutée est intégrée à la dernière minute ou - pire - entièrement oubliée.

Mais nous ne devrions pas être aussi réticents à tester notre code. Les tests contribuent à garantir la qualité des logiciels et la satisfaction des utilisateurs, donc des clients.

Dans la grande famille des tests, les tests unitaires, qui nous intéressent aujourd’hui, examinent les composants individuels d'une application pour vérifier qu'ils se comportent et fonctionnent comme prévu dans tous les scénarios possibles. L’unité ainsi testée est le plus petit composant d'un programme - par exemple, une méthode, une fonction, un module ou un objet - qui exécute généralement une seule tâche.

Dans cet article, nous allons aborder les avantages des tests unitaires. Ensuite, nous montrerons comment écrire des tests unitaires JavaScript à l'aide de Mocha et Chai.

Pourquoi s’embêter à écrire des tests unitaires ?

Détecter les défauts le plus tôt possible

Souvent, lors de la modification du code d’une application, certains comportements qui étaient jusqu’alors fonctionnels peuvent dévier, voire complètement s’arrêter de fonctionner. Sans test, ces bugs n’enverront pas forcément d’erreurs et l’équipe de développement peut se retrouver à déployer en production une application devenue instable, sans le savoir.

Les tests unitaires permettent de détecter de nombreux bugs dans les premières étapes du cycle de développement. Une implémentation correcte de ces tests unitaires, dans les pipelines de CI/CD par exemple, enverra des alertes ou empêchera même totalement de déployer une solution lorsque celle-ci ne répond plus correctement aux tests. Déceler des erreurs éventuelles le plus tôt possible permet de les réparer à moindre effort, et le blocage au niveau du déploiement limite grandement les risques de régression et les mauvaises expériences utilisateurs.

Améliorer la conception du code, son architecture et sa structure

Intégrer des tests unitaires dès le démarrage d’un projet aide à la structuration du code et à l’adoption d’une architecture propre et de bonnes pratiques.

Par exemple, donner de nombreuses responsabilités à une même fonction rend la rédaction de tests unitaires plus complexe. Le code gagnerait probablement en qualité et en lisibilité si on explosait cette fonction en plusieurs petites fonctions dîtes « pures », afin de séparer clairement les responsabilités au sein de notre code. Le TDD (Test Driven Development) répond bien à cette problématique. C’est une méthode de développement qui force à d’abord rédiger les tests qui correspondent au besoin, pour ensuite écrire et reformater le code jusqu’à valider chaque test.

Documenter par les tests

En plus de la documentation « classique » et des commentaires de code, les cas de tests définis de façon claire et descriptive peuvent être considérés comme une part non négligeable de la documentation technique d’un projet. En testant unitairement toutes les parties de notre code, on documente par la même occasion l’ensemble de celui-ci.

La lecture des tests de chaque fonctionnalité permet aux développeurs de maintenir une connaissance approfondie du système, de ses éléments entrants et sortants ainsi que du comportement attendu de chacun des modules du projet.

Mettre en place l’environnement de travail

Maintenant que les bénéfices des tests unitaires sont établis, découvrons comment les mettre en place au sein d’un projet. Pour ce faire, nous allons utiliser l’outil de bootstrap Vite. Vous pourrez retrouver ce code sur Github.

Démarrage du projet

Pour échafauder notre projet JavaScript, ouvrez un terminal et copiez-y cette commande :

npm create vite@latest test-unitaire -- --template vanilla

Puis, positionnez vous à l’intérieur du dossier généré afin de préparer l’installation des dépendances nécessaires :

cd test-unitaire

Configurer Mocha et Chai pour les tests et les assertions

Avant d’aller plus loin, vérifiez que vous avez la dernière version de Node.js installée sur votre machine.

Ensuite, installez Mocha, qui aura pour rôle d’organiser et de jouer les tests dans notre projet, en utilisant la commande suivante :

npm install --save-dev mocha

Vous remarquerez que nous installons Mocha uniquement pour le développement, car une fois en production notre application n’en aura pas l’utilité.

Mocha met à votre disposition une plateforme permettant de jouer les jeux de tests que nous allons écrire, mais il ne peut pas définir seul les conditions nécessaires pour ces tests. Pour cela, nous avons besoin d’installer une librairie qui servira à vérifier que les résultats de notre code sont corrects et qui pourra nous avertir et faire échouer les tests dans le cas contraire.

Fort heureusement, il existe de nombreuses libraires qui sont compatibles avec Mocha. Nous allons utiliser Chai pour ce tutoriel, car elle permet d’écrire des tests complexes de manière très lisible, ce qui est parfait dans le cadre de ce tutoriel.

Installez Chai avec la commande :

npm install --save-dev chai

Mettre à jour le package.json

Package.json fait parti des fichiers générés par Vite lors du démarrage du projet. Ce fichier comporte les métadonnées importantes du projet, y compris la définition des scripts qui y sont liés. Ces commandes exécutent des tâches spécifiques, comme le build du projet pour la production, son exécution en développement ou encore l’exécution des tests.

Nous devons mettre à jour les scripts décrits dans notre package.json afin d’inclure les commandes de tests afin de permettre à Mocha de les exécuter correctement. Ouvrez désormais le projet dans votre IDE favori.

Nous utiliserons VSCode pour ce tutoriel, si il est installé sur votre machine vous pouvez le démarrer directement depuis le terminal via la commande :

code .

Ensuite, ouvrez le fichier package.json. Dans la propriété scripts , ajoutez la commande de test et indiquez lui d’utiliser mocha :

scripts:{
...
test: “mocha”
}

Votre package.json devrait désormais avoir une commande de test, comme dans l’image ci-dessous :

Notre projet est maintenant suffisamment configuré pour nous permettre de démarrer l’intégration de nos test unitaires avec Mocha et Chai !

Comment écrire votre premier test unitaire avec Mocha et Chai

Dans le terminal, au niveau de la racine de votre projet, commençons par créer le fichier qui va contenir notre code :

touch addition.js

Ajoutez au sein du fichier le code suivant :

const add = (x, y) => {
  return x + y;
};

module.exports = {
  add,
};

Dans ce fichier, nous définissons la fonction add, qui va simplement renvoyer la somme de deux nombres fournis en paramètres. La déclaration module.exports exporte la fonction, permettant à d’autres fichiers JavaScript de l’importer. Sans ça, le fichier de test que nous allons créer plus tard ne pourra pas tester la fonction !

Justement, il est temps de créer un nouveau fichier dans lequel nous pourrons rédiger nos tests unitaires. Par défaut, Mocha cherchera un dossier /test à la racine du projet et effectuera tous les fichiers de tests compris dans le dossier.

Pour convenir à cette configuration de Mocha, commençons par créer le dossier :

mkdir test

Placez-vous ensuite dans le dossier, et créez le fichier qui contiendra les tests pour la fonction add définie plus tôt.

cd test
touch addition.test.js

Notez que le nom du fichier de test s’inspire du nom du fichier testé. C’est une bonne pratique qui permet d’identifier plus simplement les cas couverts par les tests. Aussi, les fichiers de tests étant des fichiers JavaScripts, l’extension .test permet de les différencier des autres.

La structure de votre projet devrait ressembler à celle-ci :

|-node modules
|-test
|  |-addition.test.js
|-addition.js
|-main.js
|-index.html
|-package.json
|-styles.css

Dans les premières lignes du fichier de test, il faut importer la fonction add depuis addition.js et la méthode expect depuis Chai :

const { add } = require("../addition.js");
const { expect } = require("chai");

La méthode expect va nous permettre d’utiliser un langage plus proche de l’anglais pour créer nos assertions. Vous allez rapidement comprendre : nous allons écrire nos premiers tests tout de suite.

Sous les imports, ajoutez le bloc de code suivant :

describe("the add function", () => {
  it("should add two numbers and return the sum", () => {
    const sum = add(4, 1);
    expect(sum).to.be.equal(5);
  });
});

Le bloc de code ci-dessus utilise deux fonctions principales : la fonction describe() et la fonction it().

La fonction describe() regroupe tous les cas de test que notre code devrait valider. Elle prend deux arguments : un nom pour le groupe de test, et une fonction de callback.

La fonction it() identifie chaque cas individuel de tests unitaires. Elle prend deux arguments : une string décrivant ce que le test devrait faire, et une fonction de callback qui contient effectivement le code du test.

Notez que l’on peut lire d’une traite et simplement la string passée en paramètre à la fonction it() en y incluant le keyword « it », ce qui donne « it should add two numbers and return the sum ». Cette façon de faire permet de signaler de façon simple et lisible l’objectif du test - dans notre cas, vérifier que la fonction add additionne correctement deux nombres.

Dans la première ligne de la fonction it(), nous appelons la fonction add importée au début de notre fichier de test avec les arguments « 4 » et « 1 », puis nous récupérons le retour de cette fonction dans la variable sum .

Lorsque l’on additionne les nombres 4 et 1, le résultat attendu est « 5 ». Nous vérifions cela dans la seconde ligne de la fonction it() en utilisant l’interface expect de Chai. Cette ligne aussi est facilement compréhensible à la lecture.

Nous pouvons maintenant exécuter notre premier test ! Il suffit de lancer dans votre terminal la commande que nous avons configurée dans le package.json :

npm test

Si votre test est validé, votre terminal devrait vous afficher quelque chose comme ça :

Ce retour affiche les groupes de tests qui ont été exécutés, en détaillant en indentation chaque cas de tests. Le symbole affiché à côté de chaque test indique le résultat du test (succès ou échec). Sont aussi affichés le nombre de tests passés (un seul dans notre cas) et le temps que les tests ont pris lors de leurs exécutions (ce qui dépend de l’environnement dans lequel ils ont été joués).

Écrire plus de tests unitaires

L’utilité des tests unitaires se révèle lorsque l’on essaye de concevoir toutes les façons dont le bout de code que nous testons devrait réagir en fonction de divers jeux de données fournis en entrée.

Par exemple, la fonction add renvoie la somme des deux valeurs numériques qu’elle reçoit en argument. Mais que se passe-t-il si elle ne reçoit qu’un seul argument numérique ? Nous pouvons écrire un test unitaire pour définir le comportement qui devrait se produire dans un tel scénario.

Pour cela, ajoutons le cas de test suivant à notre fichier de test, toujours dans le même groupe :

it("should be able to work with only one argument, and to return 0 if none are given", () => {
    const case1 = add(2)
    expect(case1).to.be.equal(2);
    const case2 = add();
    expect(case2).to.be.equal(0);
 });

Ce bloc de code permet de tester que la fonction ne produit pas de blocage quand elle ne reçoit qu’un seul nombre. Dans ce cas, elle devrait renvoyer ce nombre. Si jamais aucuns arguments ne sont fournis, la fonction devrait renvoyer « 0 ».

Si nous jouons les tests maintenant, nous pouvons nous attendre à l’échec suivant :

Ils échouent car nous n’avons pas encore implémenté le code qui permet de prendre en charge le manque d’un ou des deux arguments de la fonction. Notre code assigne le nombre 4 au paramètre x mais y n’est pas défini. Or « 4 » additionné à une valeur non définie ne renvoie pas un nombre, la fonction renvoie donc « NaN » (pour « Not a Number »).

Pour atteindre le comportement définit dans notre test, nous allons mettre à jour notre fonction add pour qu’elle utilise des paramètres par défaut, que nous initialiserons à 0 :

const add = (x = 0, y = 0) => {
  return x + y;
};

De cette façon, quand le code enverra un seul argument à notre fonction, elle assignera celui-ci à notre premier paramètre. Le second recevra la valeur de « 0 » plutôt que la valeur « undefined ». Dans notre cas, nous avons choisi « 0 » car dans une addition c’est la valeur qui n’aura pas d’impact. Notre code va donc renvoyer uniquement la valeur passée en argument s’il n’y en a qu’une, et l’addition des deux arguments à 0 quand aucune valeur n’est passée.

Maintenant que notre code est corrigé, nous pouvons relancer les tests qui devraient cette fois passer :

En conclusion

Les tests unitaires aident les développeurs à détecter les défauts de leur code plus tôt, à suivre les bonnes pratiques et à limiter l’introduction de bugs dans le code, en particuliers ceux causant des problèmes de régression.

Si l’écriture des tests unitaires peut sembler un investissement trop important en temps, ces tests permettent de dénicher les erreurs avant même leur existence, lorsqu’elles sont le plus simples à corriger, ce qui permet finalement d’économiser du temps de travail et de réflexion tout en écrivant du code de meilleure qualité.