Cours JavaScript et React · Module 13: Tester React
Mocker les modules et l'API : le simulateur de vol
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, commemockResolvedValuejest.resetAllMocks()fait la même chose et supprime aussi les réponses, donc les mocks renvoient de nouveauundefinedjest.restoreAllMocks()restaure les vraies fonctions remplacées avecjest.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. Que fait jest.mock('./api/missionApi') dans un test ?
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 :