// Tests & qualité

Écrire des tests unitaires en JavaScript avec Jest

Un test unitaire vérifie qu’un morceau de code isolé — une fonction, un module — se comporte comme prévu. En JavaScript, Jest est l’outil de référence : rapide, intégré, sans configuration pour démarrer. Ce guide couvre toute la chaîne, de l’installation à la couverture de code, en passant par les matchers, les mocks et les tests asynchrones. Chaque section s’appuie sur du code réel que vous pouvez copier tel quel.

Pourquoi écrire des tests unitaires ?

Sans tests, chaque modification est un pari. Les tests unitaires apportent trois garanties concrètes :

  • Non-régression : ils échouent immédiatement si un changement casse un comportement existant.
  • Documentation vivante : lire un fichier de test montre comment une fonction est censée s’utiliser.
  • Confiance pour refactoriser : on remanie le code sans crainte, la suite verte confirme que rien n’a bougé.

Le coût d’un bug grimpe à chaque étape : trivial à corriger en développement, coûteux en production. Un test unitaire déplace la détection le plus tôt possible.

Installer Jest

Jest s’installe comme dépendance de développement dans un projet Node. Initialisez d’abord le projet si besoin, puis ajoutez Jest.

npm init -y
npm install --save-dev jest

Ajoutez le script de test dans package.json :

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Jest reconnaît automatiquement les fichiers nommés *.test.js, *.spec.js ou placés dans un dossier __tests__. Lancez la suite avec :

npm test

La première fonction et son test

Prenons une fonction simple à tester, dans math.js :

// math.js
function additionner(a, b) {
  return a + b;
}

module.exports = { additionner };

Le test correspondant, dans math.test.js :

// math.test.js
const { additionner } = require("./math");

test("additionne deux nombres positifs", () => {
  expect(additionner(2, 3)).toBe(5);
});

Au lancement, Jest affiche la fonction testée, le nombre de tests passés et le temps d’exécution.

Structurer avec describe, it et expect

describe regroupe des tests liés, it (alias de test) décrit un cas, et expect porte l’assertion. Cette structure rend le rapport lisible.

const { additionner } = require("./math");

describe("additionner()", () => {
  it("additionne deux nombres positifs", () => {
    expect(additionner(2, 3)).toBe(5);
  });

  it("gère les nombres négatifs", () => {
    expect(additionner(-4, 1)).toBe(-3);
  });

  it("renvoie NaN si un argument n'est pas un nombre", () => {
    expect(additionner(2, undefined)).toBeNaN();
  });
});

Hooks de cycle de vie

Pour préparer et nettoyer l’environnement, Jest fournit quatre hooks : beforeAll, beforeEach, afterEach et afterAll.

describe("Panier", () => {
  let panier;

  beforeEach(() => {
    panier = new Panier(); // état neuf avant chaque test
  });

  it("commence vide", () => {
    expect(panier.total()).toBe(0);
  });
});

beforeEach garantit que chaque test part d’un état propre, sans dépendre de l’ordre d’exécution.

Les matchers essentiels

Un matcher exprime la condition à vérifier. Jest en propose des dizaines ; voici les plus utilisés, classés par usage.

Égalité

expect(2 + 2).toBe(4);                 // égalité stricte (===), pour primitives
expect({ a: 1 }).toEqual({ a: 1 });     // égalité de valeur, pour objets/tableaux
expect({ a: 1, b: undefined }).toStrictEqual({ a: 1, b: undefined });

toBe compare par référence : deux objets distincts avec le même contenu échouent. Pour comparer le contenu, utilisez toEqual.

Vérité et nullité

expect(valeur).toBeTruthy();
expect(valeur).toBeFalsy();
expect(valeur).toBeNull();
expect(valeur).toBeUndefined();
expect(valeur).toBeDefined();

Nombres

expect(2 + 2).toBeGreaterThan(3);
expect(2 + 2).toBeLessThanOrEqual(4);
expect(0.1 + 0.2).toBeCloseTo(0.3);    // évite les erreurs de flottant

Chaînes et tableaux

expect("bonjour le monde").toMatch(/monde/);
expect(["a", "b", "c"]).toContain("b");
expect(["a", "b", "c"]).toHaveLength(3);

Exceptions

Pour tester qu’une fonction lève une erreur, passez une fonction à expect (et non son résultat).

function diviser(a, b) {
  if (b === 0) throw new Error("Division par zéro");
  return a / b;
}

it("lève une erreur sur division par zéro", () => {
  expect(() => diviser(4, 0)).toThrow("Division par zéro");
});

Chaque matcher se nie avec .not : expect(x).not.toBe(y).

Les mocks : isoler le code testé

Un test unitaire ne doit tester qu’une unité. Quand la fonction dépend d’un service externe (API, base de données, horloge), on le remplace par un mock.

Fonctions mock

jest.fn() crée une fausse fonction dont on inspecte les appels.

it("appelle le callback avec chaque élément", () => {
  const callback = jest.fn();

  ["a", "b"].forEach(callback);

  expect(callback).toHaveBeenCalledTimes(2);
  expect(callback).toHaveBeenCalledWith("a", 0, ["a", "b"]);
});

On contrôle aussi la valeur renvoyée par le mock :

const getPrix = jest.fn();
getPrix.mockReturnValue(9.99);
getPrix.mockResolvedValue(9.99); // version asynchrone (promesse résolue)

Mocker un module entier

jest.mock() remplace un module importé par une version simulée. Utile pour ne pas déclencher de vrais appels réseau.

// notifier.js utilise le module "axios"
jest.mock("axios");
const axios = require("axios");
const { envoyerAlerte } = require("./notifier");

it("poste l'alerte sur l'API", async () => {
  axios.post.mockResolvedValue({ status: 200 });

  await envoyerAlerte("panne serveur");

  expect(axios.post).toHaveBeenCalledWith(
    "/api/alertes",
    { message: "panne serveur" }
  );
});

Espionner sans remplacer

jest.spyOn observe une méthode existante tout en la laissant fonctionner (ou en la court-circuitant).

const spy = jest.spyOn(console, "warn").mockImplementation(() => {});
maFonction();
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // rétablit le comportement d'origine

Tester du code asynchrone

Le code moderne repose largement sur les promesses. Jest attend correctement une promesse dès que le test renvoie ou await celle-ci. Si vous débutez sur ce terrain, notre guide async/await et les promesses pose les bases.

Avec async/await

async function getUser(id) {
  const r = await fetch(`/api/users/${id}`);
  return r.json();
}

it("récupère un utilisateur", async () => {
  const user = await getUser(1);
  expect(user).toHaveProperty("id", 1);
});

Tester un rejet

it("rejette pour un id inconnu", async () => {
  await expect(getUser(-1)).rejects.toThrow("introuvable");
});

// version résolution
it("résout avec les données", async () => {
  await expect(getUser(1)).resolves.toHaveProperty("nom");
});

Oublier le await ou le return devant expect(...).rejects est un piège classique : le test passe alors qu’il ne devrait pas, car Jest termine avant la résolution de la promesse.

Mesurer la couverture de code

La couverture indique quelle part du code est exécutée par les tests. Lancez :

npx jest --coverage

Jest produit un tableau par fichier avec quatre indicateurs : % Stmts (instructions), % Branch (branches if/else), % Funcs (fonctions) et % Lines (lignes). Un rapport HTML détaillé est généré dans coverage/lcov-report/index.html.

On peut imposer un seuil minimal dans la configuration, ce qui fait échouer la CI si la couverture chute :

// jest.config.js
module.exports = {
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 80, statements: 80 },
  },
};

Attention : viser 100 % à tout prix mène à tester du code trivial. Une couverture élevée sur la logique métier vaut mieux qu’un chiffre gonflé par des getters sans intérêt.

Bonnes pratiques : le pattern AAA

Un bon test se lit d’un coup d’œil. Le pattern Arrange-Act-Assert structure chaque cas en trois temps : préparer les données, exécuter l’action, vérifier le résultat.

it("applique une remise de 10 %", () => {
  // Arrange
  const panier = new Panier();
  panier.ajouter({ prix: 100 });

  // Act
  panier.appliquerRemise(0.1);

  // Assert
  expect(panier.total()).toBe(90);
});

Quelques règles qui font la différence :

  • Un concept par test : un it teste un comportement, pas dix. Un échec doit pointer une cause unique.
  • Des noms explicites : « renvoie 0 pour un panier vide » vaut mieux que « test panier ».
  • Pas de logique dans le test : évitez les boucles et conditions dans les assertions, elles cachent des bugs.
  • Indépendance : aucun test ne doit dépendre de l’exécution d’un autre. beforeEach remet à zéro l’état.
  • Tester les cas limites : valeurs nulles, tableaux vides, entrées invalides — c’est là que se nichent les bugs. Ces réflexes rejoignent les principes du code propre.

Aller plus loin : le TDD

Le Test-Driven Development inverse l’ordre habituel : on écrit le test avant le code. Le cycle tient en trois temps, dit « rouge-vert-refactor ».

  1. Rouge : écrire un test qui échoue, car la fonction n’existe pas encore.
  2. Vert : écrire le minimum de code pour faire passer le test.
  3. Refactor : améliorer le code, la suite verte garantissant l’absence de régression.
// 1. Rouge — on décrit le besoin avant d'implémenter
it("met la première lettre en majuscule", () => {
  expect(capitaliser("bonjour")).toBe("Bonjour");
});

// 2. Vert — implémentation minimale
function capitaliser(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

Le TDD force à réfléchir à l’interface et aux cas d’usage avant de coder, et produit naturellement du code testable. Ce n’est pas une obligation, mais un excellent réflexe à cultiver sur la logique métier critique.

À retenir

Jest s’installe en une commande et teste sans configuration. Structurez avec describe/it, exprimez les attentes avec les matchers (toBe, toEqual, toThrow), isolez les dépendances avec jest.fn() et jest.mock(), et n’oubliez jamais await sur les assertions asynchrones. Suivez le pattern AAA, gardez chaque test indépendant et concentrez la couverture sur la logique qui compte. Un test qui échoue clairement vaut mieux que dix tests illisibles.

À lire ensuite