Développement Web

Tests automatisés avec Jest : fiabiliser son code JavaScript

11 min de lecture

Ce que vous saurez faire à la fin

  1. Installer Jest et écrire le premier test
  2. Utiliser les matchers essentiels
  3. Mocker fonctions, modules, timers
  4. Tester du code async proprement
  5. Couverture 80 %+ et CI GitHub Actions

Étape 1 — Installation

npm install --save-dev jest @types/jest ts-jest
npx jest --init
// jest.config.js
module.exports = {
  testEnvironment: "node",
  preset: "ts-jest",
  coverageThreshold: {
    global: { branches: 80, functions: 85, lines: 85 }
  },
};

Étape 2 — Premier test

// src/math.js
export function tva(ht, taux = 0.18) {
  return Math.round(ht * (1 + taux));
}

// src/math.test.js
import { tva } from "./math.js";

describe("tva", () => {
  test.each([
    [1000, 0.18, 1180],
    [500, 0.10, 550],
    [0, 0.18, 0],
  ])("tva(%i, %f) = %i", (ht, taux, attendu) => {
    expect(tva(ht, taux)).toBe(attendu);
  });
});

Étape 3 — Matchers

expect(v).toBe(exact);
expect(obj).toEqual({a: 1});
expect(str).toMatch(/^FCFA/);
expect(liste).toContain("Dakar");
expect(liste).toHaveLength(3);
expect(() => fn()).toThrow("erreur");
expect(n).toBeGreaterThan(100);
expect(n).toBeCloseTo(3.14, 2);

Étape 4 — Tests async

test("fetch OK", async () => {
  const data = await fetchClient(42);
  expect(data.id).toBe(42);
});

test("rejette", async () => {
  await expect(fetchClient()).rejects.toThrow("id requis");
});

Étape 5 — Mocks de fonctions

const repo = { findById: jest.fn() };
repo.findById.mockResolvedValue({ id: 1, nom: "SARL" });

const c = await service.lire(1);
expect(repo.findById).toHaveBeenCalledWith(1);

Étape 6 — Mock d’un module

jest.mock("axios");
import axios from "axios";

test("appel API", async () => {
  axios.get.mockResolvedValue({ data: { taux: 18 } });
  const taux = await fetchTaux();
  expect(taux).toBe(18);
});

Étape 7 — Timers factices

jest.useFakeTimers();

test("timeout 30 min", () => {
  const cb = jest.fn();
  setTimeout(cb, 30 * 60 * 1000);
  jest.advanceTimersByTime(30 * 60 * 1000);
  expect(cb).toHaveBeenCalled();
});

Étape 8 — Setup/teardown

beforeAll(async () => { db = await connect(); });
afterAll(async () => { await db.end(); });
beforeEach(async () => { await db.query("TRUNCATE clients"); });
afterEach(() => { jest.clearAllMocks(); });

Étape 9 — Couverture

npx jest --coverage
# coverage/lcov-report/index.html
# Échoue si seuils coverageThreshold non atteints

Étape 10 — CI

name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20", cache: "npm" }
      - run: npm ci
      - run: npm test -- --coverage --ci

Checklist

✓ 1 test = 1 comportement
✓ Arrange / Act / Assert séparés
✓ beforeEach pour nettoyer l'état
✓ Mock uniquement les dépendances externes
✓ Couverture >= 80%
✓ Tests rapides (< 2 min au total)

Étape 1 : installer Jest 29 dans un projet Node moderne

Jest 29 reste la version stable de référence en 2026 pour les projets Node.js et frontend JavaScript. La version 30 majeure est en bêta mais non recommandée pour la production. Dans un projet existant, installez Jest et ses outils de support :

npm install --save-dev jest@29 @types/jest ts-jest
npx jest --init

L’assistant --init pose six questions et génère un fichier jest.config.js adapté. Choisissez l’environnement node pour une API Express, jsdom pour un projet React. Activez la couverture de code, qui sera essentielle pour vos pipelines CI. Sur un poste à débit modeste à Cotonou, l’installation prend 90 secondes et télécharge environ 60 Mo.

Étape 2 : structurer le projet pour les tests

Adoptez la convention __tests__ ou des fichiers .test.js à côté des sources. Pour une API Express :

src/
  services/
    user.js
    user.test.js
  routes/
    auth.js
    auth.test.js
  utils/
    fcfa.js
    fcfa.test.js

Cette colocalisation rend chaque test découvrable instantanément depuis le code de production. Évitez de centraliser tous les tests dans un dossier tests/ à la racine : la maintenance devient pénible quand le projet dépasse 50 fichiers source.

Étape 3 : écrire un premier test unitaire

Testez une fonction utilitaire qui convertit des euros en FCFA selon le taux fixe officiel de la BCEAO (1 EUR = 655,957 FCFA) :

// src/utils/fcfa.js
export function eurToFcfa(eur) {
  if (typeof eur !== 'number' || eur < 0) {
    throw new Error('Montant invalide');
  }
  return Math.round(eur * 655.957);
}

// src/utils/fcfa.test.js
import { eurToFcfa } from './fcfa.js';

describe('eurToFcfa', () => {
  test('convertit 10 EUR en 6560 FCFA', () => {
    expect(eurToFcfa(10)).toBe(6560);
  });

  test('arrondit correctement', () => {
    expect(eurToFcfa(1)).toBe(656);
  });

  test('rejette les valeurs négatives', () => {
    expect(() => eurToFcfa(-5)).toThrow('Montant invalide');
  });
});

Lancez npx jest. Vous devez voir trois tests verts en moins de 2 secondes. Si Jest râle sur les imports ESM, ajoutez "type": "module" dans package.json et lancez avec node --experimental-vm-modules node_modules/jest/bin/jest.js.

Étape 4 : tester du code asynchrone

Les API modernes manipulent des promesses. Jest gère nativement async/await :

import { fetchUser } from './user.js';

test('récupère un utilisateur par id', async () => {
  const user = await fetchUser(42);
  expect(user).toMatchObject({ id: 42, active: true });
});

test('rejette si id inconnu', async () => {
  await expect(fetchUser(999)).rejects.toThrow('not found');
});

Le matcher rejects.toThrow évite le piège du try/catch manuel qui peut faire passer un test qui devrait échouer. Toujours préférer cette forme pour valider les rejets de promesse.

Étape 5 : mocks et isolation

Pour tester une fonction qui appelle une API externe (ex. envoi SMS via Africa’s Talking), il ne faut pas appeler le vrai service. Mockez le module avec jest.mock :

jest.mock('./sms-client.js', () => ({
  sendSms: jest.fn().mockResolvedValue({ id: 'msg_123' })
}));

import { sendSms } from './sms-client.js';
import { notifyUser } from './notify.js';

test('envoie un SMS de confirmation', async () => {
  await notifyUser({ phone: '+221770000000', code: '4567' });
  expect(sendSms).toHaveBeenCalledWith({
    to: '+221770000000',
    text: expect.stringContaining('4567')
  });
});

Le mock isole le test des défaillances réseau et évite de consommer le crédit SMS de votre compte. Pour réinitialiser entre les tests, ajoutez beforeEach(() => jest.clearAllMocks());.

Étape 6 : couverture de code

Activez la couverture pour mesurer ce qui est testé : npx jest --coverage. Jest génère un rapport texte et un dossier coverage/lcov-report/index.html à ouvrir dans le navigateur. Visez 80 % de lignes couvertes au minimum, 90 % sur les modules critiques (paiement, authentification, calcul de prix). Configurez un seuil dans jest.config.js :

coverageThreshold: {
  global: { branches: 80, functions: 80, lines: 80, statements: 80 }
}

La CI échouera si la couverture passe sous le seuil, ce qui force l’équipe à écrire des tests pour chaque nouvelle fonctionnalité.

Étape 7 : tests d’intégration Express avec Supertest

Pour valider une route HTTP de bout en bout sans démarrer un vrai serveur :

import request from 'supertest';
import { app } from './app.js';

test('GET /health renvoie 200', async () => {
  const res = await request(app).get('/health');
  expect(res.status).toBe(200);
  expect(res.body).toEqual({ status: 'ok' });
});

test('POST /login refuse credentials invalides', async () => {
  const res = await request(app)
    .post('/login')
    .send({ email: 'x@y.sn', password: 'wrong' });
  expect(res.status).toBe(401);
});

Supertest démarre l’app Express en mémoire, ce qui évite les conflits de port et rend les tests reproductibles. Combinez avec un base de données SQLite éphémère pour tester les requêtes SQL sans toucher la base PostgreSQL de développement.

Étape 8 : intégrer Jest dans une CI GitHub Actions

name: tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage --ci --reporters=default --reporters=jest-junit
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage
          path: coverage/

Le flag --ci empêche Jest de mettre à jour les snapshots en silence : si un snapshot ne correspond plus, le job CI échoue, ce qui force le développeur à valider explicitement le changement avant de merger.

Dans la continuité

Combinez Jest avec notre tutoriel Docker multi-stage pour exécuter les tests dans un conteneur reproductible, ou explorez notre guide VPS hardening pour héberger la CI auto-gérée.

Étape 9 : tests de composants React avec Testing Library

Pour un projet frontend React, combinez Jest avec @testing-library/react. La philosophie : tester ce que voit l’utilisateur, pas l’implémentation interne du composant.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm.jsx';

test('affiche une erreur si email invalide', async () => {
  render(<LoginForm />);
  await userEvent.type(screen.getByLabelText(/email/i), 'pas-un-email');
  await userEvent.click(screen.getByRole('button', { name: /connexion/i }));
  expect(screen.getByText(/email invalide/i)).toBeInTheDocument();
});

Cette approche rend les tests robustes face aux refactors : tant que l’expérience utilisateur est identique, le test passe même si vous remplacez Redux par Zustand. Évitez screen.getByTestId sauf si vous n’avez pas le choix : préférer les rôles ARIA.

Étape 10 : snapshots maîtrisés

Les snapshots Jest sont utiles pour figer la sortie d’un composant ou d’une fonction qui sérialise du JSON. Mais ils sont dangereux si on les met à jour aveuglément.

test('sérialise une facture', () => {
  const invoice = generateInvoice({ amount: 50000, currency: 'XOF' });
  expect(invoice).toMatchSnapshot();
});

Règle d’équipe : aucun snapshot mis à jour sans review humaine. Configurez Husky pour bloquer les commits qui modifient des fichiers .snap sans message explicite. Sur un projet client à Lomé, cette règle a empêché trois régressions silencieuses qui auraient impacté le calcul de TVA sur 12 000 factures par mois.

Étape 11 : performance et tests parallèles

Jest exécute les fichiers de test en parallèle par défaut. Sur un MacBook M2 ou un VPS 4 vCPU, vous pouvez lancer 4 workers simultanés. Mesurez avec npx jest --logHeapUsage et ajustez --maxWorkers=50% pour ne pas saturer la machine. Les tests qui touchent une vraie base de données doivent être marqués comme séquentiels via --runInBand, sinon les transactions concurrentes provoquent des flakes intermittents impossibles à reproduire.

Étape 12 : tests flaky et stratégies de stabilisation

Un test flaky est un test qui passe ou échoue aléatoirement. Causes fréquentes : dépendance à l’horloge système (utilisez jest.useFakeTimers()), à un ordre d’itération sur un Set ou Map, à une requête réseau non mockée, à un état partagé entre tests. Discipline : tout test qui flaky deux fois en une semaine est désactivé via test.skip avec un ticket Jira ouvert. Ne le réactivez qu’après avoir éliminé la cause racine. Cette politique évite l’érosion de confiance dans la suite de tests, qui finit par être ignorée par l’équipe.

Étape 13 : checklist d’une suite Jest mature

Une suite Jest de qualité production présente sept caractéristiques. Tous les fichiers de test exécutent en moins de 60 secondes au total. Couverture supérieure à 80 % avec seuil bloquant en CI. Aucun test marqué .skip sans ticket associé. Mocks centralisés dans un dossier __mocks__ et documentés. Utilisation systématique de beforeEach pour réinitialiser l’état. Tests d’intégration séparés des tests unitaires (dossier integration/) et lancés dans un job CI distinct. Snapshots inférieurs à 200 lignes chacun, avec une politique stricte de mise à jour. Cette maturité, atteinte typiquement après 3 à 6 mois de discipline d’équipe, transforme la suite de tests en filet de sécurité réel plutôt qu’en formalité administrative.

Étape 14 : matchers personnalisés et lisibilité

Quand un domaine métier revient souvent (numéros de téléphone Sénégal, codes IBAN BCEAO, montants FCFA), créez des matchers personnalisés qui rendent les tests plus expressifs.

expect.extend({
  toBeSenegalPhone(received) {
    const ok = /^\+221(7[05678])\d{7}$/.test(received);
    return {
      pass: ok,
      message: () => `attendu numéro Sénégal valide, reçu ${received}`
    };
  }
});

test('format numéro client', () => {
  expect('+221770000000').toBeSenegalPhone();
});

Déclarez le matcher dans jest.setup.js et référencez-le dans setupFilesAfterEach de la config. La lecture des tests devient quasi naturelle, ce qui facilite la revue de code par des développeurs juniors.

Étape 15 : alternatives et décision de stack

Vitest 2.x est une alternative moderne basée sur Vite, deux à trois fois plus rapide pour les projets frontend. Si votre projet utilise Vite (Vue, SvelteKit, Astro), envisagez Vitest. Pour un projet Node pur ou React+Webpack, restez sur Jest 29 qui reste le standard du marché et bénéficie de la documentation la plus riche. Node.js 22 inclut un runner de tests natif (node --test) suffisant pour de petits modules sans dépendances, mais sans la richesse écosystème de Jest. Le choix dépend du contexte : équipe de 2 développeurs sur une lib utilitaire, tester natif. Équipe de 8 développeurs sur une plateforme métier, Jest reste la valeur sûre.

Étape 16 : tests de mutation pour mesurer la qualité réelle

La couverture de code dit que 90 % des lignes sont exécutées par les tests. Elle ne dit pas si les tests détectent réellement les bugs. Le mutation testing résout ce problème en introduisant des modifications artificielles dans le code (changer un + en -, un > en >=) puis en vérifiant que la suite Jest échoue. Outil recommandé : Stryker Mutator. Installation : npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner. Lancez npx stryker run. Sur un module bien testé, le score de mutation dépasse 80 %. En dessous de 60 %, vos tests confirment que le code s’exécute mais ne valident pas son comportement. Ce signal est nettement plus fiable que la couverture de lignes pour évaluer la robustesse réelle d’une suite de tests, et il oriente les efforts vers les zones du code où ajouter des assertions a le plus de valeur.

Note finale : maintenir la suite Jest dans le temps

Une suite de tests vit avec son code source : passez en revue chaque trimestre les tests les plus lents avec npx jest --listTests --verbose, supprimez les doublons, fusionnez les fichiers de tests sur un même module, et alignez la version de Jest avec la version de Node utilisée en production pour éviter les divergences subtiles entre environnement local et CI.

Partager