Cours JavaScript et TypeScript · Module 11: Tests avec Jest
Le mocking dans Jest
Dans cette leçon5
Quand il détecte une intrusion, le système de sécurité du parc envoie un SMS aux gardiens. Le test d'un tel système ne peut pas vraiment envoyer des messages à chaque exécution : il serait lent, dépendrait du réseau, et les gardiens cesseraient vite de réagir aux alarmes. Il nous faut une doublure : quelque chose qui ressemble à la vraie fonction, mais qui n'envoie rien et note en revanche qui l'a appelée, et avec quoi.
Le mocking consiste à créer des versions factices de fonctions, de modules ou d'objets. À Jurassic Park, c'est comme fabriquer des maquettes de dinosaures pour les tests de sécurité au lieu d'utiliser de vraies bêtes.
jest.fn() - Créer une fonction mock
jest.fn() crée une fonction « vide » qui enregistre tous ses appels. Par défaut, elle renvoie undefined, et pour vérifier son historique, on utilise les matchers de la famille toHaveBeenCalled :
1test('jest.fn() - les bases', () => {
2 // Création d'une fonction mock
3 const feedDino = jest.fn();
4
5 // Appel de la fonction mock
6 feedDino('Rex', 'meat');
7 feedDino('Brachio', 'plants');
8
9 // Vérification des appels
10 expect(feedDino).toHaveBeenCalled();
11 expect(feedDino).toHaveBeenCalledTimes(2);
12 expect(feedDino).toHaveBeenCalledWith('Rex', 'meat');
13 expect(feedDino).toHaveBeenLastCalledWith('Brachio', 'plants');
14});toHaveBeenCalled demande si la fonction a été appelée ne serait-ce qu'une fois, toHaveBeenCalledTimes combien de fois, toHaveBeenCalledWith si elle a reçu ces arguments au moins une fois, et toHaveBeenLastCalledWith avec quoi elle a été appelée la dernière fois. Le mock n'a nourri personne, il a seulement noté qu'on a essayé.
Mock avec implémentation
Tu peux donner un comportement au mock : mockReturnValue définit la valeur renvoyée à chaque appel, mockReturnValueOnce pour un seul appel, et une fonction passée à jest.fn devient une implémentation complète :
1test('mock avec implémentation', () => {
2 // Mock avec une valeur de retour
3 const getHealth = jest.fn().mockReturnValue(100);
4 expect(getHealth()).toBe(100);
5
6 // Mock avec des valeurs différentes pour les appels successifs
7 const checkSensor = jest.fn()
8 .mockReturnValueOnce('OK')
9 .mockReturnValueOnce('WARNING')
10 .mockReturnValueOnce('DANGER');
11
12 expect(checkSensor()).toBe('OK');
13 expect(checkSensor()).toBe('WARNING');
14 expect(checkSensor()).toBe('DANGER');
15
16 // Mock avec sa propre implémentation
17 const calculateDamage = jest.fn((attack, defense) => {
18 return Math.max(0, attack - defense);
19 });
20
21 expect(calculateDamage(50, 30)).toBe(20);
22 expect(calculateDamage(10, 30)).toBe(0);
23});La chaîne de mockReturnValueOnce rejoue le scénario pas à pas : le capteur signale OK, puis un avertissement, et enfin un danger. Une fois les valeurs à usage unique épuisées, le mock revient à mockReturnValue, et si celui-ci n'a pas été défini, à undefined. Pour les fonctions asynchrones, il existe des équivalents : mockResolvedValue renvoie une promesse tenue avec la valeur donnée, et mockRejectedValue une promesse rejetée.
jest.mock() - Mocker des modules
jest.mock() permet de remplacer un module entier par un mock. Regarde d'abord le module qu'on ne veut pas exécuter dans les tests - sendAlert envoie une alerte via fetch à l'API du parc :
1// Fichier : alertService.js
2export function sendAlert(zone, message) {
3 // En réalité, envoie un e-mail/SMS - on ne veut pas ça dans les tests !
4 return fetch('/api/alerts', {
5 method: 'POST',
6 body: JSON.stringify({ zone, message })
7 });
8}Dans le fichier de test, l'appel jest.mock('./alertService') remplace toutes les fonctions de ce module par des mocks qui renvoient undefined, et require récupère déjà la version substituée. SecuritySystem est la classe testée, qui utilise alertService en interne :
1// Fichier : securitySystem.test.js
2jest.mock('./alertService');
3
4const { sendAlert } = require('./alertService');
5
6describe('SecuritySystem', () => {
7 beforeEach(() => {
8 // Réinitialise les mocks avant chaque test
9 sendAlert.mockClear();
10 sendAlert.mockResolvedValue({ status: 'sent' });
11 });
12
13 it('should send alert on breach', async () => {
14 const system = new SecuritySystem();
15 await system.reportBreach('Zone A');
16
17 expect(sendAlert).toHaveBeenCalledWith(
18 'Zone A',
19 expect.stringContaining('intrusion')
20 );
21 });
22});Le test vérifie que le système a demandé l'envoi d'une alerte, mais aucun SMS n'est parti. expect.stringContaining est un matcher asymétrique : il accepte n'importe quelle chaîne contenant le fragment indiqué. Remarque aussi l'ordre : avec babel-jest, les appels à jest.mock sont automatiquement remontés au début du fichier (hoisting), donc le mock fonctionne même si l'import se trouve plus haut.
jest.mock avec une fabrique
Le mock automatique ne suffit pas toujours. Le second argument de jest.mock est une fabrique : une fonction qui renvoie un objet se faisant passer pour le module. Tu contrôles alors entièrement ce que le module exporte :
1jest.mock('./alertService', () => ({
2 sendAlert: jest.fn().mockResolvedValue({ status: 'sent' }),
3}));Cette fois, sendAlert renvoie dès le départ une promesse tenue, donc la configuration dans beforeEach n'est plus nécessaire. Le hoisting a toutefois une conséquence : la fabrique ne peut pas utiliser de variables déclarées en dehors d'elle, sauf si leur nom commence par le mot mock.
jest.spyOn() - Espionner des méthodes
spyOn surveille une méthode existante sans la remplacer (par défaut). La différence est fondamentale : jest.fn crée une nouvelle fonction vide, alors que jest.spyOn enveloppe une méthode qui existe déjà dans l'objet et continue d'appeler l'original :
1test("spyOn - surveillance d'une méthode", () => {
2 const park = {
3 dinosaurs: [],
4 addDinosaur(dino) {
5 this.dinosaurs.push(dino);
6 return this.dinosaurs.length;
7 },
8 getDinosaurCount() {
9 return this.dinosaurs.length;
10 }
11 };
12
13 // Espionne la méthode - conserve l'implémentation d'origine
14 const spy = jest.spyOn(park, 'addDinosaur');
15
16 park.addDinosaur({ name: 'Rex' });
17 park.addDinosaur({ name: 'Blue' });
18
19 // Vérifie les appels
20 expect(spy).toHaveBeenCalledTimes(2);
21 expect(spy).toHaveBeenCalledWith({ name: 'Rex' });
22
23 // La fonction d'origine fonctionne toujours !
24 expect(park.dinosaurs).toHaveLength(2);
25
26 // Restaure l'implémentation d'origine
27 spy.mockRestore();
28});L'espion a noté les deux appels, et les dinosaures sont quand même arrivés dans le parc, car la méthode d'origine a fonctionné. À la fin, mockRestore retire l'espion et rétablit la méthode telle qu'elle était avant le test. Il ne fonctionne que pour les mocks créés par spyOn.
spyOn avec remplacement de l'implémentation
Parfois, tu veux écouter une méthode tout en bloquant son vrai fonctionnement, par exemple parce qu'elle se connecte au réseau. Dans ce cas, tu ajoutes mockImplementation à spyOn. La méthode remplacée renvoie une promesse, donc le test est async et attend le résultat avec await :
1test("spyOn - remplacement de l'implémentation", async () => {
2 const api = {
3 fetchDinoData(id) {
4 // Appel normal de l'API - on ne veut pas ça dans les tests
5 return fetch(`/api/dinosaurs/${id}`);
6 }
7 };
8
9 // Remplace l'implémentation le temps du test
10 const spy = jest.spyOn(api, 'fetchDinoData').mockImplementation((id) => {
11 return Promise.resolve({
12 id,
13 name: 'Mock Rex',
14 species: 'T-Rex'
15 });
16 });
17
18 // Maintenant, fetchDinoData renvoie des données mock
19 const data = await api.fetchDinoData('001');
20 expect(data.name).toBe('Mock Rex');
21
22 spy.mockRestore();
23});Si, à la place de await, tu écrivais seulement .then(...) sans return, le test se terminerait avant que la promesse soit tenue et serait validé avant même que l'assertion s'exécute. On examinera ce piège en détail dans la leçon suivante.
Vérifier les appels des mocks
Jest fournit un riche ensemble de matchers pour les mocks. Chaque mock possède aussi une propriété mock.calls : un tableau dont chaque élément est la liste des arguments d'un appel :
1test('matchers pour les mocks', () => {
2 const callback = jest.fn();
3
4 callback('Rex');
5 callback('Blue', 'raptor');
6
7 // A-t-il été appelé ?
8 expect(callback).toHaveBeenCalled();
9
10 // Combien de fois ?
11 expect(callback).toHaveBeenCalledTimes(2);
12
13 // Avec quels arguments ?
14 expect(callback).toHaveBeenCalledWith('Rex');
15 expect(callback).toHaveBeenNthCalledWith(1, 'Rex');
16 expect(callback).toHaveBeenNthCalledWith(2, 'Blue', 'raptor');
17
18 // Accès aux détails des appels
19 expect(callback.mock.calls).toHaveLength(2);
20 expect(callback.mock.calls[0]).toEqual(['Rex']);
21 expect(callback.mock.calls[1]).toEqual(['Blue', 'raptor']);
22});toHaveBeenNthCalledWith numérote les appels à partir de 1, alors que mock.calls est un tableau ordinaire indexé à partir de 0, d'où les numéros différents dans un même test. Les matchers donnent des messages d'erreur plus lisibles, donc ne recours à mock.calls que dans des situations inhabituelles.
Nettoyer après les mocks
Un mock se souvient des appels, y compris d'un test à l'autre. C'est pourquoi, dans l'exemple avec alertService, le hook beforeEach appelle mockClear, qui efface l'historique des appels. mockReset fait la même chose et supprime en plus le comportement configuré, et mockRestore rétablit en outre la méthode d'origine après spyOn. Pour tous les mocks à la fois, il existe jest.clearAllMocks, jest.resetAllMocks et jest.restoreAllMocks, et l'option clearMocks: true de la configuration efface automatiquement l'historique avant chaque test.
Mon conseil : mocke les frontières du système, c'est-à-dire le réseau, la base de données, l'horloge et les SMS, et pas ta propre logique. Si tu remplaces tout, le test vérifiera seulement que les mocks fonctionnent. Dans la leçon suivante, mockResolvedValue rencontrera les tests de code asynchrone, et dans le laboratoire ci-dessous, tu verras comment fonctionne de l'intérieur un jest.fn() simplifié.
Retiens : un mock est la doublure du dinosaure lors d'un exercice de sécurité - il ressemble au vrai, ne mord pas et note précisément chaque mouvement.
Code de cette leçon : index.js
1// Mocking - jest.fn(), jest.mock(), spyOn
2console.log("=== Jurassic Park - Clonage et Mocking ===\n");
3
4// Simulation de jest.fn()
5function createMockFn(impl) {
6 const mock = {
7 calls: [],
8 results: [],
9 returnValues: [],
10 implementations: impl ? [impl] : [],
11 };
12
13 function mockFn(...args) {
14 mock.calls.push(args);
15 let result;
16 if (mock.returnValues.length > 0) {
17 result = mock.returnValues.shift();
18 } else if (mock.implementations.length > 0) {
19 result = mock.implementations[0](...args);
20 } else {
21 result = undefined;
22 }
23 mock.results.push(result);
24 return result;
25 }
26
27 mockFn.mock = mock;
28 mockFn.mockReturnValue = (val) => {
29 mock.implementations = [() => val];
30 return mockFn;
31 };
32 mockFn.mockReturnValueOnce = (val) => {
33 mock.returnValues.push(val);
34 return mockFn;
35 };
36 mockFn.mockImplementation = (fn) => {
37 mock.implementations = [fn];
38 return mockFn;
39 };
40 mockFn.mockClear = () => {
41 mock.calls = [];
42 mock.results = [];
43 };
44
45 return mockFn;
46}
47
48// --- jest.fn() les bases ---
49console.log("--- jest.fn() - création de fonctions mock ---\n");
50
51const feedDino = createMockFn();
52feedDino("Rex", "meat");
53feedDino("Brachio", "plants");
54
55console.log("Appels :", feedDino.mock.calls);
56console.log("Nombre d'appels :", feedDino.mock.calls.length);
57console.log("Premier appel :", feedDino.mock.calls[0]);
58console.log("Deuxième appel :", feedDino.mock.calls[1]);
59
60// --- Mock avec valeur de retour ---
61console.log("\n--- Mock avec valeur de retour ---\n");
62
63const getHealth = createMockFn();
64getHealth.mockReturnValue(100);
65console.log("getHealth() :", getHealth());
66console.log("getHealth() :", getHealth());
67
68// --- Mock avec différentes valeurs ---
69console.log("\n--- Mock avec différentes valeurs (mockReturnValueOnce) ---\n");
70
71const checkSensor = createMockFn();
72checkSensor.mockReturnValueOnce("OK");
73checkSensor.mockReturnValueOnce("WARNING");
74checkSensor.mockReturnValueOnce("DANGER");
75
76console.log("Lecture 1 :", checkSensor());
77console.log("Lecture 2 :", checkSensor());
78console.log("Lecture 3 :", checkSensor());
79
80// --- Mock avec implémentation ---
81console.log("\n--- Mock avec implémentation ---\n");
82
83const calculateDamage = createMockFn((attack, defense) => {
84 return Math.max(0, attack - defense);
85});
86
87console.log("damage(50, 30) :", calculateDamage(50, 30));
88console.log("damage(10, 30) :", calculateDamage(10, 30));
89console.log("Appels :", calculateDamage.mock.calls);
90
91// --- spyOn - espionner des méthodes ---
92console.log("\n--- spyOn - surveiller des méthodes existantes ---\n");
93
94const park = {
95 dinosaurs: [],
96 addDinosaur(dino) {
97 this.dinosaurs.push(dino);
98 return this.dinosaurs.length;
99 }
100};
101
102// Simulation de spyOn
103const originalAdd = park.addDinosaur.bind(park);
104const spyCalls = [];
105park.addDinosaur = function(...args) {
106 spyCalls.push(args);
107 return originalAdd(...args);
108};
109
110park.addDinosaur({ name: "Rex" });
111park.addDinosaur({ name: "Blue" });
112
113console.log("Appels du spy :", spyCalls.length);
114console.log("Premier appel :", JSON.stringify(spyCalls[0]));
115console.log("Dinosaures dans le parc :", park.dinosaurs.length);
116console.log("(La fonction d'origine fonctionne toujours !)");
117
118// --- Matchers pour les mocks ---
119console.log("\n--- Vérifier les appels d'un mock ---\n");
120const callback = createMockFn();
121callback("Rex");
122callback("Blue", "raptor");
123
124console.log("Appelé ?", callback.mock.calls.length > 0 ? "OUI" : "NON");
125console.log("Combien de fois ?", callback.mock.calls.length);
126console.log("Tous les appels :", JSON.stringify(callback.mock.calls));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.fn() dans Jest ?
2. Quelle est la principale différence entre jest.fn() et jest.spyOn() ?
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
Utilise des fonctions mock pour tester le système d'alarme du parc.
- Tri horizontal
Ordonne les éléments d'une configuration de valeur de retour d'un mock :
- Éditeur de code
Surveille les méthodes du système du parc avec spyOn et vérifie les appels.