Introduction aux tests unitaires avec Vitest
Jusqu’ici, pour vérifier qu’une route fonctionnait, vous démarriez le serveur, envoyiez une requête avec Bruno ou Postman, puis examiniez la réponse.
Cette méthode est utile, mais elle demande de répéter les mêmes vérifications après chaque modification. À mesure que l’application évolue, certaines risquent d’être oubliées. Un test automatisé permet de confier ces vérifications à un programme et de les répéter rapidement, sans intervention manuelle.
1. Comprendre les tests unitaires
Section intitulée « 1. Comprendre les tests unitaires »Pour une fonction qui reçoit une valeur et retourne un résultat, la question est simple : pour une entrée donnée, obtient-on le résultat attendu ? D’autres tests vérifient plutôt qu’une erreur est produite ou qu’une autre fonction est appelée.
On recherche notamment quatre qualités dans un test unitaire :
- Rapidité : il s’exécute assez vite pour être relancé fréquemment.
- Isolation : son résultat dépend le moins possible d’éléments externes, comme le réseau, une base de données ou d’autres services.
- Reproductibilité : dans les mêmes conditions, il produit le même résultat, indépendamment de l’ordre d’exécution des tests.
- Vérification automatique : il détermine lui-même si le comportement observé correspond au comportement attendu.
Les tests automatisés aident notamment à détecter les régressions. Par exemple, après avoir modifié une règle de validation, un test qui réussissait auparavant peut échouer et signaler qu’un comportement attendu a changé.
2. Installer Vitest
Section intitulée « 2. Installer Vitest »Nous utiliserons Vitest, un outil permettant d’exécuter des tests JavaScript et d’en consulter les résultats. On l’installe comme dépendance de développement, puisqu’il sert à vérifier le code pendant son développement et n’est pas nécessaire à l’exécution de l’API en production :
npm install --save-dev vitestDans le fichier package.json, on ajoute un script pour lancer les tests :
{ "scripts": { "test": "vitest run" }}Cet extrait présente uniquement la propriété scripts pertinente; il faut conserver les autres propriétés déjà présentes dans votre fichier.
Dans notre projet, nous placerons les fichiers de test dans un dossier tests/, à côté de src/, et utiliserons des noms se terminant par .test.js. Vitest reconnaît cette convention de nommage.
Pour exécuter les tests une seule fois :
npm test3. Écrire un premier test
Section intitulée « 3. Écrire un premier test »3.1 Commencer par une fonction pure
Section intitulée « 3.1 Commencer par une fonction pure »Une fonction pure constitue un bon point de départ, car son comportement est généralement facile à prévoir.
Notre API utilise notamment ces fonctions de validation dans src/regles/verifications.js :
export const estTexteNonVide = (v) => typeof v === "string" && v.trim() !== "";export const estEntierPositif = (v) => Number.isInteger(v) && v > 0;
export const regle = (condition, message) => (v) => (condition(v) ? null : message);Commençons par estEntierPositif : la fonction reçoit une valeur et retourne un booléen (true ou false).
3.2 Définir un cas de test
Section intitulée « 3.2 Définir un cas de test »import { describe, it, expect } from "vitest";import { estEntierPositif } from "../src/regles/verifications.js";
describe("estEntierPositif", () => { it("accepte un entier positif", () => { const resultat = estEntierPositif(10); expect(resultat).toBe(true); });});Trois fonctions de Vitest interviennent ici :
describeregroupe des tests liés à une même fonction ou à un même comportement.itdéfinit un cas de test. Son titre indique le comportement attendu, par exemple « accepte un entier positif ».expectintroduit une assertion, c’est-à-dire une vérification. La méthodetoBe(true)indique que le résultat doit être exactementtrue. Ces méthodes de vérification sont appelées matchers dans la documentation de Vitest.
3.3 Reconnaître un test réussi
Section intitulée « 3.3 Reconnaître un test réussi »Avec npm test, Vitest affiche un résultat semblable à celui-ci (la présentation peut varier selon la version) :
✓ tests/verifications.test.js (1 test) 2ms
Test Files 1 passed (1) Tests 1 passed (1)Le symbole ✓ indique que le test a réussi.
3.4 Comprendre un échec
Section intitulée « 3.4 Comprendre un échec »Ajoutons un second test, qui vérifie que zéro est refusé :
it("refuse zéro", () => { const resultat = estEntierPositif(0); expect(resultat).toBe(false);});Pour observer un échec, introduisons volontairement une erreur dans src/regles/verifications.js : remplaçons v > 0 par v >= 0. Le test échoue alors :
FAIL tests/verifications.test.js > estEntierPositif > refuse zéroAssertionError: expected true to be falseLe message permet de repérer le test qui a échoué, puis la différence entre le résultat obtenu (true) et le résultat attendu (false). Rétablissons ensuite v > 0 : le test réussit de nouveau.
4. Structurer un test avec AAA
Section intitulée « 4. Structurer un test avec AAA »Une convention courante consiste à organiser les tests en trois étapes, désignées par l’acronyme AAA, de l’anglais Arrange, Act, Assert :
- Préparer (Arrange) : définir les données et les conditions nécessaires au test.
- Exécuter (Act) : déclencher l’opération ou le comportement à tester.
- Vérifier (Assert) : comparer le comportement observé avec le comportement attendu.
Prenons une règle de reglesBoite, créée à partir de estEntierPositif avec la fonction regle. Cette règle retourne null si la valeur est valide, ou un message d’erreur dans le cas contraire.
it("refuse zéro", () => { // Préparer const valeur = 0;
// Exécuter const resultat = reglesBoite.capacite(valeur);
// Vérifier expect(resultat).toBe("entier positif attendu");});Dans cet exemple, la préparation est très simple. Elle sera plus importante lorsque nous devrons créer des objets représentant des requêtes et des réponses HTTP. Avec l’habitude, les commentaires peuvent être retirés; des lignes vides suffisent souvent pour séparer les trois étapes.
Deux pratiques facilitent la lecture et l’entretien des tests.
Cibler un comportement par test. Il est généralement préférable de donner à chaque test un objectif précis. Si un même test vérifie plusieurs situations et que la première assertion échoue, les suivantes ne sont pas exécutées. Il peut toutefois être nécessaire d’effectuer plusieurs opérations pour vérifier un même comportement.
Choisir un titre descriptif. Le titre « refuse zéro » indique clairement ce qui est attendu. Un titre comme « test 2 » ou « capacite » apporte peu d’information lorsqu’un test échoue.
VérificationUn test appelle reglesBoite.capacite(0), vérifie le résultat, puis appelle reglesBoite.capacite(-5) et vérifie à nouveau, le tout dans un seul it intitulé « capacite ». Quelles améliorations proposeriez-vous ?
Il vaut mieux distinguer les deux cas de test, « refuse zéro » et « refuse un nombre négatif ». Si la première assertion échoue, la seconde ne sera pas exécutée. De plus, le titre « capacite » ne précise pas le comportement attendu.
5. Choisir les cas et les comportements à tester
Section intitulée « 5. Choisir les cas et les comportements à tester »Lorsqu’on conçoit des tests, deux questions se posent : quelles situations faut-il couvrir ? et quels comportements doit-on vérifier ?
5.1 Choisir des situations représentatives
Section intitulée « 5.1 Choisir des situations représentatives »Pour une même fonction, on peut notamment envisager trois catégories de situations :
- Cas courant (ou nominal) : une entrée habituelle pour laquelle la fonction doit se comporter normalement. Pour
capacite, on pourrait utiliser10. - Cas limites : des valeurs situées aux frontières d’une condition. Pour
capacite,1est la plus petite valeur acceptée et0est la valeur entière immédiatement inférieure. - Cas invalides : des valeurs qui doivent être refusées, comme un nombre négatif, un nombre décimal, une chaîne de caractères ou une valeur absente.
Ces catégories ne sont pas exclusives : 0, par exemple, représente à la fois un cas limite et une valeur invalide.
| Catégorie | Valeur | Résultat attendu |
|---|---|---|
| Courant | 10 |
null |
| Limite | 1 |
null |
| Limite et invalide | 0 |
message d’erreur |
| Invalide | -1 |
message d’erreur |
| Invalide | 2.5 |
message d’erreur |
| Invalide | "10" |
message d’erreur |
| Invalide | undefined |
message d’erreur |
Le cas "10" est important : une chaîne qui contient les caractères d’un nombre n’est pas une valeur numérique. Si le client transmet "capacite": "10" dans une requête JSON, la règle actuelle la refuse. Le test permet de documenter et de préserver ce comportement.
VérificationUne règle accepte une note comprise entre 0 et 100 inclusivement. Quelles valeurs choisiriez-vous pour vérifier les limites ?
On peut tester -1 (refusée), 0 (acceptée), 100 (acceptée) et 101 (refusée). Ces valeurs permettent notamment de repérer des erreurs dans les opérateurs de comparaison.
5.2 Déterminer le comportement à vérifier
Section intitulée « 5.2 Déterminer le comportement à vérifier »Un test peut porter sur différents aspects du comportement d’une fonction :
- La valeur retournée. On compare le résultat obtenu avec celui qui était attendu, comme pour les règles de
reglesBoite. - Une erreur levée. On vérifie que la fonction signale une situation incorrecte en levant une erreur. Nous en verrons un exemple à la section 8.
- Une interaction avec une autre fonction. On vérifie qu’un appel a été effectué avec les bons arguments, ou au contraire qu’il n’a pas eu lieu. C’est particulièrement utile pour tester les middlewares.
6. Utiliser les méthodes de vérification de Vitest
Section intitulée « 6. Utiliser les méthodes de vérification de Vitest »Vitest fournit de nombreuses méthodes de vérification (matchers). Voici celles qui seront utiles pour les premiers tests :
| Méthode | Vérifie que… |
|---|---|
toBe(x) |
la valeur correspond exactement à x (comparaison avec Object.is) |
toEqual(x) |
la valeur a le même contenu que x (comparaison en profondeur) |
toBeNull() |
la valeur est null |
toThrow() |
la fonction lève une erreur |
toHaveBeenCalled() |
la fonction espion a été appelée |
toHaveBeenCalledWith(x) |
la fonction espion a été appelée avec x |
On peut inverser une vérification à l’aide de .not, par exemple : expect(next).not.toHaveBeenCalled().
7. Tester le comportement d’un middleware
Section intitulée « 7. Tester le comportement d’un middleware »Dans Express, un middleware reçoit généralement req, res et next. Il peut notamment transmettre le traitement au middleware suivant avec next(), envoyer une réponse ou transmettre une erreur avec next(err). Pour le tester sans démarrer le serveur, nous allons vérifier les interactions qui correspondent à son comportement attendu.
Nous pouvons fournir au middleware des objets req et res simplifiés, adaptés au test. Pour représenter res, créons un objet dont les méthodes status et json sont des fonctions espionnes :
import { vi } from "vitest";
export function fausseReponse() { const res = {}; res.status = vi.fn(() => res); // permet l'enchaînement res.status(400).json(...) res.json = vi.fn(() => res); return res;}Commençons par validerCorps, qui utilise les règles de validation testées précédemment :
import { describe, it, expect, vi } from "vitest";import { validerCorps } from "../src/intergiciels/validation.js";import { reglesBoite } from "../src/regles/boite.js";import { fausseReponse } from "./outils.js";
describe("validerCorps", () => { it("laisse passer un corps valide", () => { const req = { body: { nom: "Rangement", capacite: 10 } }; const res = fausseReponse(); const next = vi.fn();
validerCorps(reglesBoite)(req, res, next);
expect(next).toHaveBeenCalledOnce(); expect(res.status).not.toHaveBeenCalled(); });
it("retourne une réponse 400 avec les erreurs de validation", () => { const req = { body: { nom: "Rangement", capacite: 0 } }; const res = fausseReponse(); const next = vi.fn();
validerCorps(reglesBoite)(req, res, next);
expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ erreurs: [{ champ: "capacite", message: "entier positif attendu" }], }); expect(next).not.toHaveBeenCalled(); });});On retrouve la structure AAA : préparer les objets req, res et next, exécuter le middleware, puis vérifier les appels enregistrés.
Le middleware exigerRole peut être testé de la même façon. Il faut notamment prévoir le cas où aucune information d’authentification n’est présente dans req :
import { describe, it, expect, vi } from "vitest";import { exigerRole } from "../src/intergiciels/authentification.js";import { fausseReponse } from "./outils.js";
describe("exigerRole", () => { it("laisse passer un utilisateur qui détient le rôle", () => { const req = { utilisateur: { roles: ["admin"] } }; const res = fausseReponse(); const next = vi.fn();
exigerRole("admin")(req, res, next);
expect(next).toHaveBeenCalledOnce(); expect(res.status).not.toHaveBeenCalled(); });
it("retourne une réponse 403 si aucun utilisateur n'est authentifié", () => { const req = {}; const res = fausseReponse(); const next = vi.fn();
exigerRole("admin")(req, res, next);
expect(res.status).toHaveBeenCalledWith(403); expect(next).not.toHaveBeenCalled(); });});Cette seconde situation peut survenir, par exemple, si le middleware d’autorisation est exécuté avant celui qui établit l’identité de l’utilisateur. Ici, on vérifie le comportement d’exigerRole lorsque l’information nécessaire est absente, sans tester la configuration complète des middlewares Express.
8. Remplacer les dépendances et tester le code asynchrone
Section intitulée « 8. Remplacer les dépendances et tester le code asynchrone »8.1 Remplacer une dépendance avec un mock
Section intitulée « 8.1 Remplacer une dépendance avec un mock »Le middleware chargerBoite appelle service.trouverBoite, qui interroge ensuite le modèle. Si l’objectif est de tester uniquement le comportement de chargerBoite, on peut remplacer le module de service par une version contrôlée pendant le test.
Avec vi.mock, Vitest permet de remplacer un module au moment du test. Dans l’exemple suivant, service.trouverBoite devient une fonction dont nous pouvons définir le résultat avec mockResolvedValue. Cette méthode configure la valeur avec laquelle la promesse retournée sera résolue.
8.2 Tester un middleware asynchrone
Section intitulée « 8.2 Tester un middleware asynchrone »chargerBoite est une fonction async : elle retourne une promesse. Le test doit attendre la fin de cette promesse avant d’examiner le résultat. On utilise donc await et on déclare le cas de test avec async.
import { describe, it, expect, vi, beforeEach } from "vitest";import { chargerBoite } from "../src/intergiciels/boites.js";import * as service from "../src/services/boites.js";import { fausseReponse } from "./outils.js";
vi.mock("../src/services/boites.js");
describe("chargerBoite", () => { beforeEach(() => vi.resetAllMocks());
it("ajoute la boîte à req et appelle next", async () => { const boite = { nom: "Rangement", capacite: 10 }; service.trouverBoite.mockResolvedValue(boite); const req = { params: { id: "1" } }; const res = fausseReponse(); const next = vi.fn();
await chargerBoite(req, res, next);
expect(req.boite).toBe(boite); expect(next).toHaveBeenCalledOnce(); });
it("retourne une réponse 404 si la boîte n'existe pas", async () => { service.trouverBoite.mockResolvedValue(null); const req = { params: { id: "1" } }; const res = fausseReponse(); const next = vi.fn();
await chargerBoite(req, res, next);
expect(res.status).toHaveBeenCalledWith(404); expect(next).not.toHaveBeenCalled(); });});beforeEach exécute une fonction avant chaque test. Ici, vi.resetAllMocks() réinitialise les fonctions simulées, notamment leurs appels enregistrés et leurs valeurs de retour configurées, afin d’éviter que les tests s’influencent mutuellement.
trouverBoite sans avoir à préparer les données du modèle.
8.3 Vérifier une erreur dans une fonction asynchrone
Section intitulée « 8.3 Vérifier une erreur dans une fonction asynchrone »Le service creerBoite refuse de créer une boîte si son nom est déjà utilisé. Dans ce cas, il lève une erreur associée au statut HTTP 409. Lorsqu’une fonction async lève une erreur, sa promesse est rejetée. Vitest permet de vérifier ce rejet avec .rejects.
Dans cet exemple, nous utilisons le véritable modèle en mémoire. Avant chaque test, nous réinitialisons ses données avec la méthode vider() pour que les résultats d’un test ne dépendent pas des précédents.
import { describe, it, expect, beforeEach } from "vitest";import { creerBoite } from "../src/services/boites.js";import { Boite } from "../src/modeles/boite.js";
describe("creerBoite", () => { beforeEach(() => Boite.vider());
it("crée une boîte et lui attribue un identifiant", async () => { const boite = await creerBoite({ nom: "Rangement", capacite: 10 });
expect(boite).toEqual({ id: 1, nom: "Rangement", capacite: 10 }); });
it("refuse un nom déjà utilisé avec une erreur 409", async () => { await creerBoite({ nom: "Rangement", capacite: 10 });
await expect(creerBoite({ nom: "Rangement", capacite: 5 })) .rejects.toMatchObject({ status: 409 }); });});Dans le second test, la première création prépare la situation de conflit. La deuxième création constitue l’opération testée. L’assertion .rejects.toMatchObject({ status: 409 }) vérifie que la promesse est rejetée avec une erreur possédant au moins la propriété status attendue.