Cours JavaScript et React · Module 13: Tester React

Tester les hooks : diagnostic des systèmes internes

8 min de lecture
Dans cette leçon7

Les hooks personnalisés sont comme les systèmes internes d'un vaisseau spatial : moteur gravitationnel, système de survie, navigation. Tu ne les vois pas directement, mais tu dois être sûr qu'ils fonctionnent correctement. Pour tester les hooks, on utilise renderHook de React Testing Library.

On ne peut pas simplement appeler un hook dans un test comme une fonction ordinaire, car React n'autorise l'appel des hooks que pendant le rendu d'un composant. Tu pourrais écrire un faux composant pour chaque test, mais cela fait beaucoup de code répétitif qui ne vérifie rien.

renderHook : exécuter un hook de façon isolée

renderHook te permet de tester un hook sans écrire ton propre composant : la bibliothèque crée elle-même un petit composant de test et y appelle ton hook. La fonction fait partie de @testing-library/react depuis la version 13.1, et le paquet séparé @testing-library/react-hooks est déprécié :

1import { renderHook, act } from '@testing-library/react';
2import { useCounter } from './useCounter';
3
4test('initializes counter with default value', () => {
5  const { result } = renderHook(() => useCounter());
6
7  expect(result.current.count).toBe(0);
8});
9
10test('initializes counter with custom value', () => {
11  const { result } = renderHook(() => useCounter(10));
12
13  expect(result.current.count).toBe(10);
14});

On lit le résultat du hook dans result.current. La propriété current se met à jour après chaque rendu, donc ne la décompose pas dans une variable au début du test, sinon tu garderais une valeur périmée.

En plus de result, la fonction renvoie aussi rerender et unmount. Tu découvriras la première dans un instant, et la seconde démonte le composant de test, ce qui permet de vérifier si l'effet du hook fait le ménage derrière lui, par exemple en fermant une connexion ou en effaçant un intervalle.

Tester les changements d'état

Les changements d'état d'un hook doivent être enveloppés dans act(). Voici le hook de compteur qu'on va tester :

1// useCounter.js
2function useCounter(initialValue = 0) {
3  const [count, setCount] = useState(initialValue);
4
5  const increment = () => setCount(c => c + 1);
6  const decrement = () => setCount(c => c - 1);
7  const reset = () => setCount(initialValue);
8
9  return { count, increment, decrement, reset };
10}

Le hook renvoie un objet avec une valeur count et trois fonctions. Remarque que reset revient à initialValue, et non à zéro. Voici le test :

1test('increments and decrements counter', () => {
2  const { result } = renderHook(() => useCounter(5));
3
4  expect(result.current.count).toBe(5);
5
6  act(() => {
7    result.current.increment();
8  });
9  expect(result.current.count).toBe(6);
10
11  act(() => {
12    result.current.decrement();
13  });
14  expect(result.current.count).toBe(5);
15
16  act(() => {
17    result.current.reset();
18  });
19  expect(result.current.count).toBe(5);
20});

On appelle les fonctions du hook à l'intérieur de act, car elles modifient l'état en dehors des outils de RTL. La dernière assertion confirme que reset rétablit la valeur de départ, 5.

Un hook qui renvoie un tableau

Tous les hooks ne renvoient pas un objet. useToggle imite useState et renvoie une paire [valeur, basculer], que le composant peut décomposer sous n'importe quels noms, par exemple const [shieldsOn, toggleShields] = useToggle() :

1function useToggle(initialValue = false) {
2  const [value, setValue] = useState(initialValue);
3  const toggle = useCallback(() => setValue(v => !v), []);
4  return [value, toggle];
5}

La bascule utilise la mise à jour fonctionnelle v => !v. Avec setValue(!value), deux appels dans le même act verraient la même ancienne valeur et le second n'annulerait pas le premier. Dans le test, tu accèdes aux éléments du tableau par leur indice :

1test('toggles shields on and off', () => {
2  const { result } = renderHook(() => useToggle(false));
3
4  expect(result.current[0]).toBe(false);
5
6  act(() => {
7    result.current[1]();
8  });
9  expect(result.current[0]).toBe(true);
10
11  act(() => {
12    result.current[1]();
13  });
14  expect(result.current[0]).toBe(false);
15});

result.current[0] est la valeur et result.current[1] la fonction de bascule. Ne les décompose pas au début du test avec const [value, toggle] = result.current, car value resterait une copie périmée, comme avec un objet. Tu testes de la même façon un hook de liste qui renvoie par exemple { items, add, remove, clear } : tu appelles l'action dans act et tu vérifies result.current.items.

Tester les hooks avec effets

Le hook de carburant récupère les données dans useEffect, donc le résultat arrive de façon asynchrone :

1// useFuelGauge.js
2function useFuelGauge(shipId) {
3  const [fuel, setFuel] = useState(null);
4  const [loading, setLoading] = useState(true);
5
6  useEffect(() => {
7    fetch(`/api/ships/${shipId}/fuel`)
8      .then(res => res.json())
9      .then(data => {
10        setFuel(data.level);
11        setLoading(false);
12      });
13  }, [shipId]);
14
15  return { fuel, loading };
16}

L'état loading commence à true et ne change qu'après la réponse du serveur. Le test doit donc attendre ce changement :

1test('fetches fuel level for ship', async () => {
2  global.fetch = jest.fn().mockResolvedValue({
3    json: () => Promise.resolve({ level: 85 })
4  });
5
6  const { result } = renderHook(() => useFuelGauge('enterprise'));
7
8  // Au départ : chargement
9  expect(result.current.loading).toBe(true);
10  expect(result.current.fuel).toBeNull();
11
12  // Après le chargement
13  await waitFor(() => {
14    expect(result.current.loading).toBe(false);
15  });
16  expect(result.current.fuel).toBe(85);
17});

waitFor répète l'assertion jusqu'à ce que loading devienne false. À partir de là, on peut vérifier le niveau de carburant en toute sécurité. La doublure global.fetch garde le test hors du réseau et renvoie toujours le même niveau, 85, donc le résultat est reproductible.

Tester les hooks avec des props qui changent

Quand un hook dépend de props, on utilise rerender. L'option initialProps fournit les arguments du premier rendu :

1test('refetches fuel when shipId changes', async () => {
2  global.fetch = jest.fn()
3    .mockResolvedValueOnce({
4      json: () => Promise.resolve({ level: 85 })
5    })
6    .mockResolvedValueOnce({
7      json: () => Promise.resolve({ level: 42 })
8    });
9
10  const { result, rerender } = renderHook(
11    ({ shipId }) => useFuelGauge(shipId),
12    { initialProps: { shipId: 'enterprise' } }
13  );
14
15  await waitFor(() => {
16    expect(result.current.fuel).toBe(85);
17  });
18
19  // Changer shipId : nouveau rendu avec de nouvelles props
20  rerender({ shipId: 'discovery' });
21
22  await waitFor(() => {
23    expect(result.current.fuel).toBe(42);
24  });
25});

Les deux mockResolvedValueOnce définissent les réponses dans l'ordre des appels. rerender avec un nouveau shipId relance l'effet, et le hook récupère le carburant du second vaisseau.

Tester les hooks avec contexte

Les hooks qui utilisent un contexte nécessitent un wrapper. Voici un hook qui lance une erreur quand le fournisseur manque :

1// useTheme.js
2const ThemeContext = createContext();
3
4function useTheme() {
5  const context = useContext(ThemeContext);
6  if (!context) throw new Error('useTheme must be used within ThemeProvider');
7  return context;
8}

Une telle exception est une bonne pratique : le développeur sait tout de suite qu'il a oublié le fournisseur. Le test vérifie les deux chemins :

1test('returns theme from context', () => {
2  const wrapper = ({ children }) => (
3    <ThemeContext.Provider value={{ mode: 'dark', color: 'blue' }}>
4      {children}
5    </ThemeContext.Provider>
6  );
7
8  const { result } = renderHook(() => useTheme(), { wrapper });
9
10  expect(result.current.mode).toBe('dark');
11  expect(result.current.color).toBe('blue');
12});
13
14test('throws error without provider', () => {
15  expect(() => {
16    renderHook(() => useTheme());
17  }).toThrow('useTheme must be used within ThemeProvider');
18});

L'option wrapper enveloppe le composant de test dans le fournisseur. Dans React 19, au lieu de ThemeContext.Provider, tu peux écrire la forme courte <ThemeContext value={...}>. Dans le second test, l'erreur lancée pendant le rendu ressort de renderHook comme une exception ordinaire, donc toThrow l'attrape. Avec React 18, un tel test encombrait en plus la console d'un message d'erreur qu'on faisait taire avec jest.spyOn(console, 'error'). Avec React 19, la console reste propre, donc l'espion n'est plus nécessaire.

Tester un hook useReducer

Un hook basé sur useReducer garde sa logique dans une fonction reducer pure :

1// useMissionControl.js
2function missionReducer(state, action) {
3  switch (action.type) {
4    case 'START_MISSION':
5      return { ...state, status: 'active', mission: action.payload };
6    case 'ABORT_MISSION':
7      return { ...state, status: 'aborted', mission: null };
8    case 'COMPLETE_MISSION':
9      return { ...state, status: 'completed' };
10    default:
11      return state;
12  }
13}
14
15function useMissionControl() {
16  const [state, dispatch] = useReducer(missionReducer, {
17    status: 'idle',
18    mission: null
19  });
20
21  const startMission = (name) => dispatch({ type: 'START_MISSION', payload: name });
22  const abortMission = () => dispatch({ type: 'ABORT_MISSION' });
23  const completeMission = () => dispatch({ type: 'COMPLETE_MISSION' });
24
25  return { ...state, startMission, abortMission, completeMission };
26}

Le reducer reçoit l'état et une action, et renvoie un nouvel état. Le hook enveloppe dispatch dans des fonctions lisibles comme startMission. Le test parcourt tout le cycle de vie de la mission :

1test('manages mission lifecycle', () => {
2  const { result } = renderHook(() => useMissionControl());
3
4  // État initial
5  expect(result.current.status).toBe('idle');
6  expect(result.current.mission).toBeNull();
7
8  // Démarrer la mission
9  act(() => result.current.startMission('Apollo'));
10  expect(result.current.status).toBe('active');
11  expect(result.current.mission).toBe('Apollo');
12
13  // Terminer la mission
14  act(() => result.current.completeMission());
15  expect(result.current.status).toBe('completed');
16});

Le reducer est une fonction pure, donc tu peux aussi le tester seul, sans renderHook : tu lui passes un état et une action, puis tu vérifies le résultat.

Dans la prochaine leçon, tu prendras une « photo » de l'interface rendue et tu découvriras le snapshot testing.

Mon conseil : teste un hook avec renderHook seulement s'il est utilisé dans de nombreux composants, et sinon teste le composant qui s'en sert. Retiens : renderHook est le banc de diagnostic où tu vérifies un système du vaisseau avant de l'installer dans le cockpit.

Code de cette leçon : App.jsx
1import React, { useState, useReducer, useCallback } from 'react';
2
3// ===== Hooks personnalisés (dans les tests, tu les lances avec renderHook) =====
4
5function useCounter(initialValue = 0) {
6  const [count, setCount] = useState(initialValue);
7  const increment = useCallback(() => setCount(c => c + 1), []);
8  const decrement = useCallback(() => setCount(c => c - 1), []);
9  const reset = useCallback(() => setCount(initialValue), [initialValue]);
10  return { count, increment, decrement, reset };
11}
12
13// Un hook qui renvoie un tableau [valeur, basculer], comme useState
14function useToggle(initialValue = false) {
15  const [value, setValue] = useState(initialValue);
16  const toggle = useCallback(() => setValue(v => !v), []);
17  return [value, toggle];
18}
19
20function missionReducer(state, action) {
21  switch (action.type) {
22    case 'START_MISSION':
23      return { ...state, status: 'active', mission: action.payload };
24    case 'ABORT_MISSION':
25      return { ...state, status: 'aborted', mission: null };
26    case 'COMPLETE_MISSION':
27      return { ...state, status: 'completed' };
28    default:
29      return state;
30  }
31}
32
33function useMissionControl() {
34  const [state, dispatch] = useReducer(missionReducer, {
35    status: 'idle',
36    mission: null,
37  });
38  const startMission = (name) => dispatch({ type: 'START_MISSION', payload: name });
39  const abortMission = () => dispatch({ type: 'ABORT_MISSION' });
40  const completeMission = () => dispatch({ type: 'COMPLETE_MISSION' });
41  return { ...state, startMission, abortMission, completeMission };
42}
43
44// ===== Composants de démonstration =====
45
46const STATUS_LABELS = {
47  idle: 'en attente',
48  active: 'en cours',
49  aborted: 'annulée',
50  completed: 'terminée',
51};
52
53function CounterDemo() {
54  const { count, increment, decrement, reset } = useCounter(0);
55  return (
56    <div className="demo-card">
57      <h3>Hook useCounter</h3>
58      <div className="counter-display">{count}</div>
59      <div className="btn-group">
60        <button onClick={decrement}>-1</button>
61        <button onClick={reset}>Réinitialiser</button>
62        <button onClick={increment}>+1</button>
63      </div>
64    </div>
65  );
66}
67
68function ToggleDemo() {
69  const [engineOn, toggleEngine] = useToggle(false);
70  const [shieldsOn, toggleShields] = useToggle(true);
71  return (
72    <div className="demo-card">
73      <h3>Hook useToggle</h3>
74      <div className="toggle-row">
75        <span>Moteur : {engineOn ? 'ALLUMÉ' : 'ÉTEINT'}</span>
76        <button onClick={toggleEngine} aria-pressed={engineOn}>Basculer le moteur</button>
77      </div>
78      <div className="toggle-row">
79        <span>Boucliers : {shieldsOn ? 'ACTIVÉS' : 'DÉSACTIVÉS'}</span>
80        <button onClick={toggleShields} aria-pressed={shieldsOn}>Basculer les boucliers</button>
81      </div>
82    </div>
83  );
84}
85
86function MissionDemo() {
87  const mission = useMissionControl();
88  const [input, setInput] = useState('');
89  return (
90    <div className="demo-card">
91      <h3>Hook useMissionControl</h3>
92      <p>Statut : <strong>{STATUS_LABELS[mission.status]}</strong></p>
93      {mission.mission && <p>Mission : {mission.mission}</p>}
94      {mission.status !== 'active' && (
95        <div className="mission-form">
96          <input
97            value={input}
98            onChange={e => setInput(e.target.value)}
99            placeholder="Nom de la mission..."
100            aria-label="Nom de la mission"
101          />
102          <button
103            disabled={!input.trim()}
104            onClick={() => {
105              mission.startMission(input.trim());
106              setInput('');
107            }}
108          >
109            Démarrer
110          </button>
111        </div>
112      )}
113      {mission.status === 'active' && (
114        <div className="btn-group">
115          <button onClick={mission.abortMission} className="danger">Annuler</button>
116          <button onClick={mission.completeMission} className="success">Terminer</button>
117        </div>
118      )}
119    </div>
120  );
121}
122
123export default function App() {
124  return (
125    <div className="app">
126      <h1>Tester les hooks</h1>
127      <p className="subtitle">renderHook + act() = diagnostic des systèmes du vaisseau</p>
128      <p className="note">
129        L’aperçu montre les hooks en action. Dans Jest, tu vérifies les mêmes hooks
130        avec renderHook, sans cliquer.
131      </p>
132      <CounterDemo />
133      <ToggleDemo />
134      <MissionDemo />
135    </div>
136  );
137}

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 renderHook de React Testing Library ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Termine deux hooks personnalisés de la soute du vaisseau. useToggle(initialValue) renvoie [value, toggle], et useList(initialItems) renvoie { items, add, remove, clear }. ___BLANK1___ : toggle bascule la valeur à l'inverse avec une fonction de mise à jour (avec un paramètre current), car toggle est mémorisé par useCallback et ne voit pas la nouvelle value. ___BLANK2___ : add ajoute l'élément à la fin d'un nouveau tableau. ___BLANK3___ : remove(index) renvoie un nouveau tableau sans l'élément à l'indice donné. Ne modifie pas le tableau current (push, splice) : le test gèle le tableau initial. Le test appelle les hooks avec renderHook et les actions avec act, y compris deux à la suite dans un même act.

  • Tri vertical

    Ordonne les étapes du test d'un hook personnalisé dans le bon ordre :

  • Éditeur de code

    Termine les onglets ARIA de la console du commandant. ___BLANK1___ : chaque bouton d'onglet a le rôle tab (le conteneur a déjà role="tablist" et le contenu role="tabpanel"), donc le test trouve les onglets avec getAllByRole('tab'). ___BLANK2___ : isActive est vrai seulement pour l'onglet dont l'id est égal à activeId ; il va dans aria-selected et dans le style. ___BLANK3___ : un clic sur un onglet enregistre son id dans l'état. Le test vérifie qu'au départ le premier onglet est sélectionné, qu'après un clic la sélection se déplace, et que le panneau affiche le contenu de l'onglet choisi et porte son nom (aria-labelledby).

  • Clique dans l'ordre

    Ordonne les éléments de la syntaxe renderHook avec result dans le bon ordre :

  • Tri horizontal

    Ordonne les éléments pour accéder à la valeur de retour d'un hook dans le bon ordre :

  • Éditeur de code

    Termine le centre de notifications de la mission AlertSystem. Les boutons ajoutent des notifications de trois types (info, warning, danger), chacune avec un bouton de fermeture, et Tout effacer supprime toute la liste. ___BLANK1___ : une nouvelle notification va à la fin de la liste dans un nouveau tableau (les précédentes restent). ___BLANK2___ : removeAlert(id) renvoie un nouveau tableau sans la notification qui a l'id donné, et les autres restent, y compris celles du même type. ___BLANK3___ : une alarme (type danger) a le rôle alert, et l'info et l'avertissement ont le rôle status. Le test clique sur les boutons, compte les éléments avec les rôles alert et status et ferme une notification choisie avec son bouton.

Articles utiles