Cours JavaScript et React · Module 13: Tester React

Jest : le moteur-fusée des tests

9 min de lecture
Dans cette leçon9

Avant chaque lancement, les ingénieurs vérifient des centaines de paramètres : pression du carburant, température des moteurs, communications. Personne ne le fait à la main après chaque modification de la fusée, car cela prendrait des semaines. Dans le code, c'est pareil : après chaque correction, tu veux savoir en quelques secondes si rien n'est cassé, et il te faut un outil qui lance tous les contrôles d'un coup.

Jest est un framework de test créé par Facebook (Meta) qui constitue la base des tests d'applications React. Depuis 2022, il est développé au sein de la fondation OpenJS. Considère-le comme le moteur-fusée principal : sans lui, aucun test ne décolle. Jest réunit tout dans un seul paquet : le lancement des tests, les assertions, les mocks et le rapport de couverture.

Où Jest cherche les tests

Par défaut, Jest trouve les fichiers qui se terminent par .test.js ou .spec.js (et aussi .jsx, .ts, .tsx) dans n'importe quel dossier du projet, ainsi que tous les fichiers des dossiers __tests__. On place généralement le test à côté du composant, par exemple SpaceShip.test.js près de SpaceShip.js. Ces tests de fonctions et de composants isolés sont des tests unitaires : ils forment la base de la pyramide des tests, car ce sont les plus rapides et les moins coûteux.

Structure d'un test : describe et it/test

On organise les tests en blocs describe (qui décrivent un groupe de tests) et it ou test (tests individuels). describe regroupe des tests liés, et à l'intérieur, chaque it vérifie un comportement grâce à la fonction expect. Dans l'exemple, createSpaceShip() crée un vaisseau au réservoir plein (fuel égal à 100) avec une méthode travel qui consomme du carburant et lance l'erreur No fuel quand le réservoir est vide :

1describe('SpaceShip', () => {
2  it('should have full fuel at start', () => {
3    const ship = createSpaceShip();
4    expect(ship.fuel).toBe(100);
5  });
6
7  it('should consume fuel during travel', () => {
8    const ship = createSpaceShip();
9    ship.travel(50);
10    expect(ship.fuel).toBeLessThan(100);
11  });
12
13  test('should not travel without fuel', () => {
14    const ship = createSpaceShip();
15    ship.fuel = 0;
16    expect(() => ship.travel(10)).toThrow('No fuel');
17  });
18});

it et test sont des alias : ils font exactement la même chose. La convention dit : it('should...') ou test('returns...'). Chaque test crée son propre vaisseau, donc les tests ne partagent aucun état et peuvent s'exécuter dans n'importe quel ordre.

Matchers (assertions) : les capteurs de diagnostic

Les matchers sont des fonctions qui vérifient si le résultat correspond à ce qu'on attend. Jest en offre un riche éventail. Le schéma est toujours le même : expect(valeur), puis un point et un matcher avec l'attente.

Comparaison de valeurs

Le plus souvent, tu compares un résultat avec une valeur attendue. C'est ici que se cache la distinction la plus importante de tout Jest :

1// Égalité exacte
2expect(2 + 2).toBe(4);
3
4// Comparaison d'objets (égalité profonde)
5expect({ name: 'Apollo' }).toEqual({ name: 'Apollo' });
6
7// Négation
8expect(fuel).not.toBe(0);

toBe compare avec Object.is, donc pour deux objets distincts il renvoie faux même si leur contenu est identique. Pour les objets et les tableaux, utilise toEqual, qui vérifie le contenu champ par champ.

Véracité (truthiness)

Le groupe suivant de matchers vérifie si une valeur existe et comment elle se comporte dans une condition if :

1expect(null).toBeNull();
2expect(undefined).toBeUndefined();
3expect('Apollo').toBeDefined();
4expect(true).toBeTruthy();
5expect(0).toBeFalsy();

toBeTruthy et toBeFalsy sont permissifs, car 0, une chaîne vide et null sont tous des valeurs fausses. Quand tu connais la valeur exacte, choisis un matcher précis.

Nombres

Les nombres ont leurs propres comparaisons, pratiques pour la vitesse ou le niveau de carburant :

1expect(speed).toBeGreaterThan(0);
2expect(fuel).toBeLessThanOrEqual(100);
3expect(0.1 + 0.2).toBeCloseTo(0.3);

toBeCloseTo te sauve avec les décimales, car 0.1 + 0.2 vaut 0.30000000000000004 en JavaScript, et toBe(0.3) échouerait.

Chaînes de caractères

Tu peux vérifier un texte avec une expression régulière ou en cherchant un fragment :

1expect('Mission Control').toMatch(/mission/i);
2expect('Apollo 13').toContain('Apollo');

Le drapeau i de l'expression rend la vérification insensible à la casse.

Tableaux et objets

Les collections ont des matchers pour vérifier le contenu, la longueur et les propriétés :

1expect(['Earth', 'Mars', 'Jupiter']).toContain('Mars');
2expect(crewMembers).toHaveLength(5);
3expect(spaceship).toHaveProperty('engine');
4expect(spaceship).toHaveProperty('crew.captain', 'Nova');

toHaveProperty accepte un chemin avec des points, donc il atteint le champ imbriqué crew.captain et compare sa valeur.

Exceptions

Pour vérifier des erreurs, pense à envelopper l'appel dans une fonction fléchée :

1expect(() => launchWithoutFuel()).toThrow();
2expect(() => divideByZero()).toThrow('Division by zero');
3expect(() => invalidInput()).toThrow(ValidationError);

Si tu écrivais expect(launchWithoutFuel()), l'erreur serait lancée avant expect et le test planterait au lieu de l'attraper.

Fonctions pures : la cible la plus facile pour les tests

Avant de vérifier du code, mieux vaut l'écrire de façon à ce qu'il soit facile à vérifier. Regarde une fonction qui consomme le carburant stocké dans une variable extérieure :

1// Fonction impure : elle modifie une variable extérieure
2let fuel = 100;
3
4function burnFuel(amount) {
5  fuel = fuel - amount;
6  return fuel;
7}

Le résultat de burnFuel(10) dépend du nombre d'appels précédents : la première fois, elle renvoie 90, la deuxième fois 80. Un test qui attend 90 passe ou échoue selon l'ordre des tests. Une fonction pure n'a pas ce problème : pour les mêmes arguments, elle renvoie toujours le même résultat et ne modifie rien en dehors d'elle. Elle traite aussi les données invalides de façon prévisible, en lançant une erreur que le test attrape avec toThrow :

1// Fonction pure : le résultat dépend uniquement des arguments
2function calculateTravelTime(distance, speed) {
3  if (speed <= 0) {
4    throw new Error('Speed must be positive');
5  }
6  return distance / speed;
7}
8
9test('calculates travel time', () => {
10  expect(calculateTravelTime(300, 100)).toBe(3);
11});
12
13test('throws when speed is not positive', () => {
14  expect(() => calculateTravelTime(300, 0)).toThrow('Speed must be positive');
15});

Chaque test, c'est un appel et un expect, sans préparation ni nettoyage. C'est pourquoi tu dois placer la logique à vérifier, comme les calculs de carburant ou la validation de coordonnées, dans des fonctions pures séparées, et laisser le composant se contenter d'afficher leur résultat.

Setup et teardown : préparation avant le lancement

Les tests nécessitent souvent une préparation et un nettoyage de l'environnement. Jest fournit pour cela quatre fonctions de cycle de vie :

1describe('MissionControl', () => {
2  let missionControl;
3
4  // Avant CHAQUE test
5  beforeEach(() => {
6    missionControl = new MissionControl();
7    missionControl.initialize();
8  });
9
10  // Après CHAQUE test
11  afterEach(() => {
12    missionControl.shutdown();
13  });
14
15  // Avant TOUS les tests du groupe
16  beforeAll(() => {
17    console.log('Starting mission control tests');
18  });
19
20  // Après TOUS les tests du groupe
21  afterAll(() => {
22    console.log('All tests completed');
23  });
24
25  it('should have zero active missions at start', () => {
26    expect(missionControl.activeMissions).toBe(0);
27  });
28});

Mon conseil : préfère beforeEach, car chaque test reçoit un objet tout neuf. Garde beforeAll pour les opérations coûteuses, comme le démarrage d'un serveur de test.

Tests asynchrones dans Jest

Beaucoup d'opérations dans l'espace prennent du temps, et c'est pareil dans le code. Jest doit savoir qu'il faut attendre le résultat :

1// Retourner une Promise
2test('fetches crew data', () => {
3  return fetchCrewData().then(data => {
4    expect(data).toHaveLength(5);
5  });
6});
7
8// Async/await (préféré)
9test('fetches crew data', async () => {
10  const data = await fetchCrewData();
11  expect(data).toHaveLength(5);
12});
13
14// Tester une Promise rejetée
15test('throws on invalid mission', async () => {
16  await expect(fetchMission(-1)).rejects.toThrow('Invalid mission ID');
17});

Sans return ni await, le test se terminerait avant la réponse et passerait sans rien vérifier.

Les fonctions mock de Jest : les simulateurs de vol

Les fonctions mock te permettent de simuler le comportement d'une fonction sans réellement l'appeler. jest.fn() crée une fonction qui enregistre chaque appel :

1// Créer une fonction mock
2const mockLaunch = jest.fn();
3
4// L'appeler
5mockLaunch('Apollo', 'Moon');
6
7// Vérifier
8expect(mockLaunch).toHaveBeenCalled();
9expect(mockLaunch).toHaveBeenCalledTimes(1);
10expect(mockLaunch).toHaveBeenCalledWith('Apollo', 'Moon');
11
12// Mock avec valeur de retour
13const mockFuel = jest.fn().mockReturnValue(100);
14expect(mockFuel()).toBe(100);
15
16// Mock avec implémentation
17const mockCalculateRoute = jest.fn((from, to) => {
18  return { distance: 1000, estimatedTime: '2h' };
19});

Le mock ne lance pas de vraie fusée, et pourtant tu peux vérifier combien de fois et avec quels arguments il a été appelé. On verra le mock de modules entiers dans une prochaine leçon.

Lancer les tests

Jest offre plusieurs commandes utiles pour lancer les tests. On les appelle généralement via le script test du package.json :

1# Lancer tous les tests
2npm test
3
4# Lancer les tests en mode watch (automatiquement après chaque changement)
5npm test -- --watch
6
7# Lancer les tests d'un fichier spécifique
8npm test -- SpaceShip.test.js
9
10# Lancer les tests correspondant à un motif
11npm test -- --testPathPatterns="mission"
12
13# Lancer avec le rapport de couverture de code
14npm test -- --coverage

Le double tiret transmet les options à Jest. Depuis Jest 30, l'option de filtrage des chemins s'appelle --testPathPatterns, et l'ancienne --testPathPattern se termine par une erreur. Le mode --watch ne relance que les tests liés aux fichiers modifiés et détecte les changements grâce à git, il fonctionne donc dans un dépôt. Dans un dossier sans git, Jest te demandera d'utiliser --watchAll, qui relance tous les tests après chaque changement.

Bonnes pratiques pour tester avec Jest

  1. Un comportement par test : chaque test doit vérifier une chose précise ; plusieurs assertions sur le même comportement sont acceptables
  2. Des noms descriptifs : it('should calculate fuel consumption for 100km trip') plutôt que it('test fuel')
  3. Arrange-Act-Assert : préparer les données, exécuter l'action, vérifier le résultat
  4. Isolation : les tests ne doivent pas dépendre les uns des autres ni de l'ordre d'exécution

Dans la prochaine leçon, tu combineras Jest avec React Testing Library et tu commenceras à tester des composants.

Jest est un puissant moteur-fusée pour les tests : le maîtriser te donnera l'assurance que ton application fonctionne correctement dans toutes les conditions cosmiques !

Code de cette leçon : App.jsx
1import React, { useState } from 'react';
2
3// ===== Fonctions à tester =====
4
5function calculateFuelConsumption(distance, speed) {
6  if (speed <= 0) throw new Error('La vitesse doit être positive');
7  return distance / speed * 1.5;
8}
9
10function sortByPriority(missions) {
11  return [...missions].sort((a, b) => a.priority - b.priority);
12}
13
14// ===== Un expect miniature =====
15// L’aperçu ne lance pas Jest, donc cette fonction imite trois de ses matchers.
16// Comme dans Jest, une assertion ratée lance une erreur.
17
18function expect(actual) {
19  return {
20    toBe(expected) {
21      if (Object.is(actual, expected)) return;
22      if (JSON.stringify(actual) === JSON.stringify(expected)) {
23        throw new Error('même contenu, mais deux objets différents - compare-les avec toEqual');
24      }
25      throw new Error('attendu ' + JSON.stringify(expected) + ', reçu ' + JSON.stringify(actual));
26    },
27    toEqual(expected) {
28      if (JSON.stringify(actual) !== JSON.stringify(expected)) {
29        throw new Error('attendu ' + JSON.stringify(expected) + ', reçu ' + JSON.stringify(actual));
30      }
31    },
32    toThrow(message) {
33      try {
34        actual();
35      } catch (error) {
36        if (error.message === message) return;
37        throw new Error('une erreur avec un autre message a été lancée : ' + error.message);
38      }
39      throw new Error('la fonction n’a pas lancé d’erreur');
40    },
41  };
42}
43
44function runTest(name, fn) {
45  try {
46    fn();
47    return { name, passed: true };
48  } catch (error) {
49    return { name, passed: false, details: error.message };
50  }
51}
52
53// ===== Un rapport à la manière de Jest =====
54
55function TestResult({ name, passed, details }) {
56  return (
57    <div className={'test-result ' + (passed ? 'pass' : 'fail')}>
58      <span className="icon">{passed ? '√' : '×'}</span>
59      <span className="name">{name}</span>
60      {details && <span className="details">{details}</span>}
61    </div>
62  );
63}
64
65function TestSuite({ title, tests }) {
66  const allPassed = tests.every((t) => t.passed);
67  return (
68    <div className="test-suite">
69      <h3>
70        <span className={'badge ' + (allPassed ? 'pass' : 'fail')}>
71          {allPassed ? 'PASS' : 'FAIL'}
72        </span>
73        {title}
74      </h3>
75      {tests.map((test) => (
76        <TestResult key={test.name} {...test} />
77      ))}
78    </div>
79  );
80}
81
82const MISSIONS = [
83  { name: 'Mars', priority: 2 },
84  { name: 'Lune', priority: 1 },
85];
86
87export default function App() {
88  const [matcher, setMatcher] = useState('toBe');
89  const other = matcher === 'toBe' ? 'toEqual' : 'toBe';
90
91  const fuelTests = [
92    runTest('calcule 3 unités pour 100 km à 50 km/s', () => {
93      expect(calculateFuelConsumption(100, 50)).toBe(3);
94    }),
95    runTest('renvoie 0 pour une distance de 0', () => {
96      expect(calculateFuelConsumption(0, 50)).toBe(0);
97    }),
98    runTest('lance une erreur à une vitesse de 0', () => {
99      expect(() => calculateFuelConsumption(100, 0)).toThrow('La vitesse doit être positive');
100    }),
101  ];
102
103  const sortTests = [
104    runTest('trie les missions par priorité (' + matcher + ')', () => {
105      expect(sortByPriority(MISSIONS))[matcher]([
106        { name: 'Lune', priority: 1 },
107        { name: 'Mars', priority: 2 },
108      ]);
109    }),
110  ];
111
112  const all = [...fuelTests, ...sortTests];
113  const passedCount = all.filter((t) => t.passed).length;
114
115  return (
116    <div className="app">
117      <h1>Jest - Le moteur-fusée des tests</h1>
118      <p>Ci-dessous, tu vois les résultats des tests exécutés « en direct » dans l’aperçu.</p>
119      <TestSuite title="calculateFuelConsumption" tests={fuelTests} />
120      <TestSuite title="sortByPriority" tests={sortTests} />
121      <button className="switch" onClick={() => setMatcher(other)}>
122        Remplacer {matcher} par {other}
123      </button>
124      <p className="hint">
125        toBe compare avec Object.is, donc il vérifie s’il s’agit du même objet.
126        sortByPriority renvoie un nouveau tableau, donc seul toEqual peut vérifier son contenu.
127      </p>
128      <p className="summary">
129        Réussis : {passedCount}, échoués : {all.length - passedCount}, total : {all.length}
130      </p>
131    </div>
132  );
133}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel type de test se trouve à la base de la pyramide des tests et est le plus rapide ?

  2. 2. À quoi sert le bloc describe() dans Jest ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Complète deux fonctions de navigation pures que les tests vérifient (tu vois les résultats dans le panneau des tests, et l'aperçu affiche un mini rapport PASS/FAIL). ___BLANK1___ : calculateFuelConsumption lève une erreur quand la vitesse n'est pas supérieure à zéro (0 ou un nombre négatif). ___BLANK2___ : la fonction renvoie la consommation de carburant comme dans la leçon : la distance divisée par la vitesse, multipliée par 1.5 (calculateFuelConsumption(100, 50) donne 3). ___BLANK3___ : isValidCoordinate renvoie true seulement pour un nombre de -1000 à 1000 inclus, et false pour tout le reste : les nombres hors de l'intervalle, NaN, Infinity, les chaînes comme '42', null et undefined.

  • Clique dans l'ordre

    Compose une assertion Jest qui vérifie que result vaut 42 :

Articles utiles