Cours JavaScript et React · Module 13: Tester React

Mocker les modules et l'API : le simulateur de vol

8 min de lecture
Dans cette leçon4

Dans un vrai centre de contrôle de vol, on utilise des simulateurs pour entraîner les pilotes sans risquer d'endommager un vrai vaisseau. Dans les tests React, le mocking joue exactement le même rôle : il permet de tester les composants sans véritables connexions aux API, aux bases de données ou aux services externes.

Un test qui appelle le vrai serveur à chaque fois est lent, dépend du réseau et peut échouer la nuit simplement parce que l'API a eu une coupure. Un mock remplace le monde extérieur par une doublure prévisible que tu contrôles toi-même.

jest.mock() : remplacer les modules

jest.mock() te permet de remplacer un module entier par son mock. Chaque fonction exportée devient automatiquement une doublure jest.fn() :

1// Mocker tout le module API
2jest.mock('./api/missionApi');
3
4import { fetchMission, launchMission } from './api/missionApi';
5
6test('displays mission data', async () => {
7  // Définir ce que le mock doit retourner
8  fetchMission.mockResolvedValue({
9    id: 1,
10    name: 'Apollo 13',
11    status: 'Active'
12  });
13
14  render(<MissionPanel />);
15
16  expect(await screen.findByText('Apollo 13')).toBeInTheDocument();
17  expect(fetchMission).toHaveBeenCalledTimes(1);
18});

jest.mock se trouve au-dessus de l'import, mais même s'il était plus bas, babel-jest le remonterait tout en haut du fichier. Grâce à cela, l'import reçoit déjà la version mockée, et on fixe son résultat avec mockResolvedValue. La fonction launchMission, que ce test n'utilise pas, est elle aussi une doublure et renvoie undefined tant que tu ne fixes pas son résultat.

Mocking avec implémentation

Le second argument de jest.mock est une fabrique qui renvoie ta propre version du module. C'est utile quand les doublures automatiques qui renvoient undefined ne suffisent pas :

1jest.mock('./utils/navigation', () => ({
2  calculateRoute: jest.fn((from, to) => ({
3    distance: 100,
4    duration: '2 hours',
5    fuel: 50
6  })),
7  isValidCoordinate: jest.fn(() => true),
8}));

Chaque fonction reste un jest.fn, donc tu peux vérifier ses appels, mais elle renvoie tout de suite des données sensées.

Mocking partiel

Parfois, tu veux remplacer une seule fonction et garder le reste du module réel :

1jest.mock('./utils/spacemath', () => ({
2  ...jest.requireActual('./utils/spacemath'),
3  // Remplacer UNE SEULE fonction
4  calculateGravity: jest.fn(() => 9.81),
5}));

jest.requireActual charge le module d'origine, et la décomposition ... copie tous ses exports. On ne surcharge que calculateGravity, et tout le reste fonctionne comme en production.

Mocker fetch / axios

Quand un composant appelle lui-même fetch ou axios, on peut remplacer exactement ces fonctions.

Mocker le fetch global

global.fetch est une fonction globale, donc avant chaque test on la remplace par une doublure toute neuve :

1beforeEach(() => {
2  global.fetch = jest.fn();
3});
4
5afterEach(() => {
6  jest.restoreAllMocks();
7});
8
9test('fetches planets from API', async () => {
10  fetch.mockResolvedValueOnce({
11    ok: true,
12    json: () => Promise.resolve([
13      { id: 1, name: 'Mars' },
14      { id: 2, name: 'Jupiter' },
15    ]),
16  });
17
18  render(<PlanetList />);
19
20  expect(await screen.findByText('Mars')).toBeInTheDocument();
21  expect(screen.getByText('Jupiter')).toBeInTheDocument();
22  expect(fetch).toHaveBeenCalledWith('/api/planets');
23});

La doublure renvoie un objet avec un champ ok et une méthode json, car ce sont les seules choses qu'utilise le composant. Attention à jest.restoreAllMocks() : il ne restaure les originaux que pour les mocks créés avec jest.spyOn. Un global.fetch assigné à la main n'est écrasé qu'au beforeEach suivant. Pour remettre à zéro les compteurs d'appels, utilise jest.clearAllMocks().

Nettoyer les mocks entre les tests

Un mock garde en mémoire ses appels d'un test à l'autre, donc le compteur d'un test peut faire échouer l'assertion toHaveBeenCalledTimes du suivant. C'est pourquoi on place le nettoyage dans beforeEach :

1beforeEach(() => {
2  jest.clearAllMocks();
3});

Les trois fonctions de nettoyage se confondent facilement :

  • jest.clearAllMocks() remet à zéro l'historique des appels (mock.calls, mock.results), mais garde les réponses configurées, comme mockResolvedValue
  • jest.resetAllMocks() fait la même chose et supprime aussi les réponses, donc les mocks renvoient de nouveau undefined
  • jest.restoreAllMocks() restaure les vraies fonctions remplacées avec jest.spyOn

Les options clearMocks, resetMocks et restoreMocks de la configuration de Jest donnent le même effet sans écrire de beforeEach.

Mocker axios

On traite la bibliothèque axios comme n'importe quel module. Pour taper du texte, on utilise userEvent.setup() :

1jest.mock('axios');
2import axios from 'axios';
3import userEvent from '@testing-library/user-event';
4
5test('posts new mission', async () => {
6  const user = userEvent.setup();
7  axios.post.mockResolvedValue({
8    data: { id: 42, name: 'New Mission', status: 'Created' }
9  });
10
11  render(<CreateMissionForm />);
12
13  await user.type(screen.getByLabelText('Mission Name'), 'Artemis');
14  await user.click(screen.getByRole('button', { name: 'Create' }));
15
16  expect(axios.post).toHaveBeenCalledWith('/api/missions', {
17    name: 'Artemis'
18  });
19  expect(await screen.findByText('Mission created!')).toBeInTheDocument();
20});

On vérifie deux choses : que axios.post a reçu la bonne adresse et les bonnes données, et que l'utilisateur a vu le message de succès.

MSW (Mock Service Worker) : le mocking d'API avancé

Mocker fetch ou axios directement fonctionne, mais présente un inconvénient : tu testes l'implémentation (si tu as utilisé fetch ou axios) et non le comportement (si les données ont bien été récupérées). MSW résout ce problème en interceptant les requêtes au niveau du réseau. C'est comme un simulateur qui imite une vraie communication satellite : ton composant « croit » parler à un vrai serveur, mais en réalité c'est MSW qui génère les réponses. Cela rend le test plus réaliste et plus résistant aux changements d'implémentation. Le code ci-dessous utilise l'API de MSW 2, http et HttpResponse. L'ancien rest avec res(ctx.json()) est la syntaxe de MSW 1, supprimée dans la version 2 :

1import { http, HttpResponse } from 'msw';
2import { setupServer } from 'msw/node';
3
4// Définir les handlers
5const handlers = [
6  http.get('/api/missions', () => {
7    return HttpResponse.json([
8      { id: 1, name: 'Apollo 11', status: 'Completed' },
9      { id: 2, name: 'Artemis I', status: 'Active' },
10    ]);
11  }),
12
13  http.post('/api/missions', async ({ request }) => {
14    const body = await request.json();
15    return HttpResponse.json(
16      { id: 3, name: body.name, status: 'Created' },
17      { status: 201 }
18    );
19  }),
20];
21
22// Créer le serveur
23const server = setupServer(...handlers);
24
25// Setup et teardown
26beforeAll(() => server.listen());
27afterEach(() => server.resetHandlers());
28afterAll(() => server.close());
29
30test('lists missions from API', async () => {
31  render(<MissionList />);
32
33  expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
34  expect(screen.getByText('Artemis I')).toBeInTheDocument();
35});

setupServer de msw/node démarre l'interception dans l'environnement Node où tourne Jest. Le handler du POST lit le corps de la requête avec await request.json(), comme le ferait un vrai serveur.

Dans Jest, MSW demande deux corrections de configuration. D'abord, MSW a besoin des objets globaux Request, Response et fetch, que l'environnement jsdom n'a pas : avec un simple testEnvironment: 'jsdom', le test échoue avec l'erreur Request is not defined. La documentation de MSW recommande le paquet jest-fixed-jsdom et testEnvironment: 'jest-fixed-jsdom'. Ensuite, certaines dépendances de MSW ne sont publiées qu'en modules ESM, donc avec un Node antérieur à la version 24.9, Jest signale Must use import to load ES Module. Dans ce cas, ajoute les paquets cités dans le message aux exceptions de transformIgnorePatterns et configure transform pour que babel-jest traite aussi les fichiers .mjs.

Surcharger les handlers dans un seul test

server.use ajoute un handler pour un seul test, et resetHandlers dans afterEach restaure la configuration initiale :

1test('handles server error', async () => {
2  // Uniquement pour ce test : retourner une erreur
3  server.use(
4    http.get('/api/missions', () => {
5      return HttpResponse.json({ error: 'Server error' }, { status: 500 });
6    })
7  );
8
9  render(<MissionList />);
10
11  expect(await screen.findByText('Failed to load missions')).toBeInTheDocument();
12});

C'est ainsi que tu testes le chemin d'erreur sans modifier les handlers communs. Le composant reçoit le statut 500 et affiche un message de panne.

Mocker les timers

Beaucoup de composants utilisent setTimeout ou setInterval : par exemple, le rafraîchissement automatique des données toutes les 30 secondes, le compte à rebours du lancement d'une mission, ou des animations. Dans les tests, on ne veut pas attendre 30 vraies secondes ! Jest fournit des fake timers qui permettent d'« avancer le temps » comme dans un simulateur de vol. Appelle jest.useFakeTimers(), puis jest.advanceTimersByTime(ms) pour sauter d'un nombre donné de millisecondes. Prenons un panneau qui récupère les données au montage, puis toutes les interval millisecondes :

1function AutoRefreshPanel({ fetchData, interval }) {
2  const [data, setData] = useState(null);
3
4  useEffect(() => {
5    const load = () => fetchData().then(setData);
6    load();
7    const id = setInterval(load, interval);
8    return () => clearInterval(id);
9  }, [fetchData, interval]);
10
11  return <p>{data ? 'Données actualisées' : 'Chargement...'}</p>;
12}

Le test donne au composant un mock de fetchData qui renvoie tout de suite des données, puis avance l'horloge d'intervalles complets :

1test('auto-refreshes data every 30 seconds', async () => {
2  jest.useFakeTimers();
3  const mockFetch = jest.fn().mockResolvedValue({ data: [] });
4
5  render(<AutoRefreshPanel fetchData={mockFetch} interval={30000} />);
6
7  // Premier appel au montage
8  expect(mockFetch).toHaveBeenCalledTimes(1);
9
10  // Après 30 secondes : deuxième appel
11  await act(async () => {
12    jest.advanceTimersByTime(30000);
13  });
14  expect(mockFetch).toHaveBeenCalledTimes(2);
15
16  // Après 30 secondes de plus
17  await act(async () => {
18    jest.advanceTimersByTime(30000);
19  });
20  expect(mockFetch).toHaveBeenCalledTimes(3);
21
22  jest.useRealTimers();
23});

Chaque saut de 30 secondes déclenche un cycle de rafraîchissement, donc le compteur d'appels augmente de un. Le mock renvoie une Promise, donc le nouvel état n'apparaît qu'une fois cette Promise résolue, un instant après le saut de l'horloge. C'est pourquoi on utilise la version asynchrone await act(async () => ...) : elle attend aussi ces promesses avant de se terminer. Avec un simple act(() => ...), les compteurs seraient justes, mais React afficherait dans la console trois avertissements « not wrapped in act(...) ».

Mon conseil : pour les composants qui parlent à une API, choisis MSW, et garde jest.mock pour les modules sans accès réseau. Dans la prochaine leçon, tu testeras tes propres hooks. Retiens : un mock est un simulateur de vol où tu répètes chaque scénario, panne comprise, avant de partir dans le vrai cosmos.

Code de cette leçon : App.jsx
1import React, { useState, useEffect, useCallback } from 'react';
2
3// ===== Module API (dans un projet, ce serait un fichier missionApi.js séparé) =====
4// L’aperçu n’a pas de serveur, le module simule donc le réseau : Promise + setTimeout.
5// Dans un test, tu le remplaces avec jest.mock('./missionApi') ou MSW.
6const STATUS_LABELS = { completed: 'terminée', active: 'active', created: 'créée' };
7
8let serverMissions = [
9  { id: 1, name: 'Apollo 11', status: 'completed' },
10  { id: 2, name: 'Artemis I', status: 'active' },
11];
12let serverDown = false;
13
14const missionApi = {
15  fetchMissions() {
16    return new Promise((resolve, reject) => {
17      setTimeout(() => {
18        if (serverDown) reject(new Error('HTTP 500'));
19        else resolve([...serverMissions]);
20      }, 700);
21    });
22  },
23  createMission(data) {
24    return new Promise(resolve => {
25      setTimeout(() => {
26        const created = { id: Date.now(), name: data.name, status: 'created' };
27        serverMissions = [...serverMissions, created];
28        resolve(created);
29      }, 400);
30    });
31  },
32  setServerDown(value) {
33    serverDown = value;
34  },
35};
36
37// Le composant qui reçoit l’API mockée dans les tests
38function MissionCenter() {
39  const [missions, setMissions] = useState([]);
40  const [loading, setLoading] = useState(true);
41  const [error, setError] = useState(null);
42  const [newName, setNewName] = useState('');
43
44  const loadMissions = useCallback(async () => {
45    setLoading(true);
46    try {
47      // Dans les tests : missionApi.fetchMissions.mockResolvedValue([...])
48      const data = await missionApi.fetchMissions();
49      setMissions(data);
50      setError(null);
51    } catch (err) {
52      setError('Impossible de charger les missions');
53    } finally {
54      setLoading(false);
55    }
56  }, []);
57
58  useEffect(() => {
59    loadMissions();
60  }, [loadMissions]);
61
62  const handleCreate = async (e) => {
63    e.preventDefault();
64    if (!newName.trim()) return;
65    const created = await missionApi.createMission({ name: newName.trim() });
66    setMissions(prev => [...prev, created]);
67    setNewName('');
68  };
69
70  if (loading) return <div className="status" role="status">Chargement des missions...</div>;
71
72  if (error) {
73    return (
74      <div className="status error">
75        <p role="alert">{error}</p>
76        <button className="refresh" onClick={loadMissions}>Réessayer</button>
77      </div>
78    );
79  }
80
81  return (
82    <div className="mission-center">
83      <h2>Centre des missions</h2>
84
85      <form className="create-form" onSubmit={handleCreate}>
86        <input
87          type="text"
88          placeholder="Nom de la nouvelle mission..."
89          value={newName}
90          onChange={e => setNewName(e.target.value)}
91          aria-label="Nom de la mission"
92        />
93        <button type="submit">Créer</button>
94      </form>
95
96      <ul className="mission-list">
97        {missions.map(m => (
98          <li key={m.id}>{m.name} - {STATUS_LABELS[m.status]}</li>
99        ))}
100      </ul>
101
102      <button className="refresh" onClick={loadMissions}>
103        Actualiser
104      </button>
105    </div>
106  );
107}
108
109// Rafraîchissement automatique : dans les tests, tu avances le temps avec des fake timers
110function AutoRefreshStatus({ interval = 5000 }) {
111  const [systemStatus, setSystemStatus] = useState({
112    engine: 'nominal',
113    shields: 100,
114    lastUpdate: new Date().toLocaleTimeString('fr-FR'),
115  });
116  const [refreshCount, setRefreshCount] = useState(0);
117
118  useEffect(() => {
119    const timer = setInterval(() => {
120      setRefreshCount(c => c + 1);
121      setSystemStatus(prev => ({
122        ...prev,
123        shields: Math.max(prev.shields - 1, 0),
124        lastUpdate: new Date().toLocaleTimeString('fr-FR'),
125      }));
126    }, interval);
127
128    return () => clearInterval(timer);
129  }, [interval]);
130
131  return (
132    <div className="auto-refresh">
133      <h2>État des systèmes (rafraîchi toutes les {interval / 1000} s)</h2>
134      <div className="status-grid">
135        <div>Moteur : {systemStatus.engine}</div>
136        <div>Boucliers : {systemStatus.shields} %</div>
137        <div>Dernière lecture : {systemStatus.lastUpdate}</div>
138        <div>Rafraîchissements : {refreshCount}</div>
139      </div>
140    </div>
141  );
142}
143
144export default function App() {
145  const [down, setDown] = useState(false);
146  const [run, setRun] = useState(0);
147
148  const toggleServer = (e) => {
149    missionApi.setServerDown(e.target.checked);
150    setDown(e.target.checked);
151    setRun(r => r + 1);
152  };
153
154  return (
155    <div className="app">
156      <h1>Mocker les modules et l’API</h1>
157      <p className="info">
158        L’aperçu n’exécute pas Jest. Ici, le module missionApi simule le serveur
159        (Promise + setTimeout), et dans les tests de la leçon tu le remplaces par un mock.
160      </p>
161      <label className="server-toggle">
162        <input type="checkbox" checked={down} onChange={toggleServer} />
163        Simuler une panne du serveur (HTTP 500)
164      </label>
165      <MissionCenter key={run} />
166      <AutoRefreshStatus interval={3000} />
167    </div>
168  );
169}

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. Que fait jest.mock('./api/missionApi') dans un test ?

  2. 2. Quel est le principal avantage de MSW (Mock Service Worker) par rapport au mocking de fetch ?

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes du cycle de vie d'un mock dans les tests dans le bon ordre :

  • Éditeur de code

    Termine le reducer missionReducer et le hook useMissionTracker du journal des missions. ___BLANK1___ : l'action START passe le statut d'une mission planifiée à 'active' en renvoyant un nouvel objet avec les autres champs de la mission (ne modifie pas l'objet qui est dans l'état). ___BLANK2___ : l'action REMOVE renvoie un nouveau tableau sans la mission dont l'id est dans l'action (action.id). ___BLANK3___ : le hook passe le reducer missionReducer à useReducer. Le test vérifie le reducer comme une fonction pure sur un état gelé (Object.freeze), et lance le hook dans un petit composant de test : il ajoute deux missions, les démarre, les termine et les supprime.

  • Clique dans l'ordre

    Ordonne les éléments de la syntaxe jest.fn().mockResolvedValue() dans le bon ordre :

  • Tri horizontal

    Ordonne les éléments de la syntaxe beforeEach avec réinitialisation du mock dans le bon ordre :

Articles utiles