Cours JavaScript et React · Module 13: Tester React

Tester les composants asynchrones : des signaux venus de l'espace lointain

8 min de lecture
Dans cette leçon5

Beaucoup d'opérations dans les applications React fonctionnent de façon asynchrone : récupérer des données depuis une API, charger des ressources, des mises à jour différées. C'est comme recevoir des signaux de l'espace lointain : la réponse n'arrive pas immédiatement, il faut attendre.

Un test ordinaire vérifie l'écran juste après render. Si les données arrivent 50 ms plus tard, l'assertion s'est exécutée depuis longtemps et le test échoue, alors que le composant fonctionne. Il nous faut des outils qui attendent le signal, mais pas indéfiniment.

waitFor : attendre le signal

La fonction waitFor répète la vérification d'une assertion jusqu'à ce qu'elle réussisse ou que le délai expire. Le callback que tu lui passes peut être appelé de nombreuses fois, donc il ne doit contenir que des assertions :

1import { render, screen, waitFor } from '@testing-library/react';
2
3test('loads mission data from API', async () => {
4  render(<MissionDetails missionId="apollo-13" />);
5
6  // Le spinner est visible immédiatement
7  expect(screen.getByText('Loading...')).toBeInTheDocument();
8
9  // Attendre les données
10  await waitFor(() => {
11    expect(screen.getByText('Apollo 13')).toBeInTheDocument();
12  });
13
14  // Le spinner devrait avoir disparu
15  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
16});

L'ordre des assertions reproduit la vie du composant : d'abord le spinner, puis les données, et enfin le spinner disparaît. La dernière vérification utilise queryByText, car getByText lancerait une erreur une fois l'élément disparu.

Configuration de waitFor

Tu peux modifier les délais par défaut avec le second argument :

1await waitFor(
2  () => {
3    expect(screen.getByText('Data loaded')).toBeInTheDocument();
4  },
5  {
6    timeout: 3000,   // Temps d'attente maximal (par défaut 1000ms)
7    interval: 100,    // Fréquence de vérification (par défaut 50ms)
8  }
9);

N'augmente timeout que si une opération dure vraiment plus longtemps, car les longues attentes ralentissent toute la suite de tests.

Les requêtes findBy : un raccourci pour waitFor

Les requêtes findBy sont un raccourci qui combine waitFor avec getBy. Elles renvoient une Promise qui se résout quand l'élément apparaît :

1// Ces deux approches sont équivalentes :
2
3// Méthode 1 : waitFor + getBy
4await waitFor(() => {
5  expect(screen.getByText('Mission loaded')).toBeInTheDocument();
6});
7
8// Méthode 2 : findBy (recommandée !)
9const element = await screen.findByText('Mission loaded');
10expect(element).toBeInTheDocument();

Les deux écritures font la même chose, mais findBy est plus court et dit plus clairement ce qu'on attend. Garde waitFor pour les cas où tu attends autre chose que l'apparition d'un élément, par exemple l'appel d'un mock.

Exemple avec un composant qui récupère des données

Voici un composant typique qui récupère la liste de l'équipage après son montage. Il a trois états : chargement, erreur et données :

1function CrewList({ shipId }) {
2  const [crew, setCrew] = useState([]);
3  const [loading, setLoading] = useState(true);
4  const [error, setError] = useState(null);
5
6  useEffect(() => {
7    fetch(`/api/ships/${shipId}/crew`)
8      .then(res => res.json())
9      .then(data => {
10        setCrew(data);
11        setLoading(false);
12      })
13      .catch(err => {
14        setError(err.message);
15        setLoading(false);
16      });
17  }, [shipId]);
18
19  if (loading) return <p>Loading crew...</p>;
20  if (error) return <p>Error: {error}</p>;
21
22  return (
23    <ul>
24      {crew.map(member => (
25        <li key={member.id}>{member.name}</li>
26      ))}
27    </ul>
28  );
29}

L'effet avec fetch s'exécute après le premier rendu, donc « Loading crew... » s'affiche toujours en premier. C'est justement cet état transitoire qu'on va vérifier.

Tester ce composant

Dans un test, on ne veut pas du vrai réseau. On remplace global.fetch par un mock jest.fn() et on décide de ce qu'il renvoie :

1// Mock de fetch
2beforeEach(() => {
3  global.fetch = jest.fn();
4});
5
6test('displays crew members after loading', async () => {
7  fetch.mockResolvedValueOnce({
8    json: () => Promise.resolve([
9      { id: 1, name: 'Captain Nova' },
10      { id: 2, name: 'Engineer Bolt' },
11    ])
12  });
13
14  render(<CrewList shipId="enterprise" />);
15
16  // État de chargement
17  expect(screen.getByText('Loading crew...')).toBeInTheDocument();
18
19  // Attendre les données
20  expect(await screen.findByText('Captain Nova')).toBeInTheDocument();
21  expect(screen.getByText('Engineer Bolt')).toBeInTheDocument();
22  expect(screen.queryByText('Loading crew...')).not.toBeInTheDocument();
23});
24
25test('displays error when fetch fails', async () => {
26  fetch.mockRejectedValueOnce(new Error('Network error'));
27
28  render(<CrewList shipId="enterprise" />);
29
30  expect(await screen.findByText('Error: Network error')).toBeInTheDocument();
31});

mockResolvedValueOnce renvoie un objet qui imite une réponse avec une méthode json, tandis que mockRejectedValueOnce simule une panne réseau. On couvre donc les deux chemins, succès et erreur, sans une seule vraie requête.

Quand chaque appel de fetch doit recevoir la même réponse, une seule ligne avec mockResolvedValue, sans Once, suffit :

1const data = [{ id: 1, name: 'Captain Nova' }];
2global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve(data) });

mockResolvedValue répond de la même façon à chaque appel, alors que mockResolvedValueOnce ne répond qu'au prochain. Choisis la version avec Once quand les appels successifs doivent recevoir des réponses différentes, par exemple d'abord une erreur, puis des données.

act() : synchronisation avec React

La fonction act() indique à React qu'on effectue une opération qui met à jour l'état et nécessite un nouveau rendu. Voyons-le sur un compte à rebours avant le lancement. Le composant diminue le compteur chaque seconde avec setInterval :

1function CountdownTimer({ seconds }) {
2  const [remaining, setRemaining] = useState(seconds);
3
4  useEffect(() => {
5    const id = setInterval(() => {
6      setRemaining(r => (r > 0 ? r - 1 : 0));
7    }, 1000);
8    return () => clearInterval(id);
9  }, []);
10
11  return <p>{remaining}</p>;
12}

Deux détails comptent dans les tests. La mise à jour fonctionnelle setRemaining(r => ...) reçoit toujours la valeur la plus récente, même si la fonction de l'intervalle a été créée au premier rendu et ne verrait seule que le remaining initial. Le nettoyage clearInterval arrête l'horloge quand le composant disparaît, et RTL démonte le composant après chaque test, donc l'intervalle ne continue pas à tourner pendant le test suivant.

Passons au test. On importe act depuis le paquet react, car l'ancien act de react-dom/test-utils est déprécié dans React 19 :

1import { act } from 'react';
2
3test('updates timer display', () => {
4  jest.useFakeTimers();
5  render(<CountdownTimer seconds={10} />);
6
7  expect(screen.getByText('10')).toBeInTheDocument();
8
9  // Avancer le temps de 3 secondes
10  act(() => {
11    jest.advanceTimersByTime(3000);
12  });
13
14  expect(screen.getByText('7')).toBeInTheDocument();
15
16  jest.useRealTimers();
17});

jest.useFakeTimers() remplace l'horloge, et advanceTimersByTime(3000) l'avance de trois secondes sans attendre. Avancer l'horloge change l'état en dehors de RTL, c'est pourquoi on l'enveloppe dans act. À la fin, on restaure les vrais timers pour ne pas perturber les autres tests.

Quand a-t-on besoin de act() ?

  • React Testing Library enveloppe automatiquement dans act() la fonction render, les événements de fireEvent et de userEvent, ainsi que l'attente dans waitFor et findBy
  • Tu dois l'utiliser manuellement quand tu mets à jour l'état en dehors des outils de RTL (par exemple, avec des fake timers ou des appels directs de callbacks)

Si tu vois dans la console l'avertissement « not wrapped in act(...) », c'est le signe qu'une mise à jour d'état a eu lieu après la fin du test ou en dehors des outils de RTL.

Tester les opérations avec debounce et throttle

Un champ de recherche avec délai (debounce) n'envoie pas de requête à chaque touche : il attend que l'utilisateur arrête de taper. Voici un tel composant. Chaque nouveau caractère modifie query, et le nettoyage de l'effet annule le setTimeout précédent, donc onSearch ne se déclenche qu'après 300 ms de silence :

1function SearchBar({ onSearch, debounceMs = 300 }) {
2  const [query, setQuery] = useState('');
3
4  useEffect(() => {
5    if (!query) return;
6    const id = setTimeout(() => onSearch(query), debounceMs);
7    return () => clearTimeout(id);
8  }, [query, debounceMs, onSearch]);
9
10  return (
11    <input
12      aria-label="Rechercher une mission"
13      value={query}
14      onChange={e => setQuery(e.target.value)}
15    />
16  );
17}

Le test combine les fake timers avec userEvent. L'option advanceTimers indique à la bibliothèque comment avancer l'horloge remplacée :

1test('calls onSearch once, after the user stops typing', async () => {
2  jest.useFakeTimers();
3  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
4  const onSearch = jest.fn();
5
6  render(<SearchBar onSearch={onSearch} debounceMs={300} />);
7
8  await user.type(screen.getByRole('textbox'), 'Mars');
9
10  // Avant l'expiration du debounce : encore aucune requête
11  expect(onSearch).not.toHaveBeenCalled();
12
13  // Après l'expiration du debounce
14  act(() => {
15    jest.advanceTimersByTime(300);
16  });
17
18  expect(onSearch).toHaveBeenCalledTimes(1);
19  expect(onSearch).toHaveBeenCalledWith('Mars');
20
21  jest.useRealTimers();
22});

Sans l'option advanceTimers, user.type attendrait de vrais délais entre les frappes et le test resterait bloqué. Après la saisie, on avance le temps de 300 ms, exactement la durée du debounce. Quatre touches, mais un seul appel avec le mot complet Mars : c'est précisément la promesse du debounce que ce test vérifie. Le throttle se teste de la même façon, sauf que tu attends au plus un appel par intervalle de temps.

waitForElementToBeRemoved

Attendre qu'un élément disparaisse (par exemple, un spinner). Tu importes la fonction depuis @testing-library/react, comme waitFor :

1test('hides loading spinner after data loads', async () => {
2  render(<DataPanel />);
3
4  // Le spinner est visible
5  const spinner = screen.getByText('Loading...');
6
7  // Attendre qu'il disparaisse
8  await waitForElementToBeRemoved(spinner);
9
10  // Les données devraient être visibles
11  expect(screen.getByText('Data ready')).toBeInTheDocument();
12});

L'élément doit être trouvé avant qu'il ne disparaisse, c'est pourquoi getByText vient avant waitForElementToBeRemoved. La fonction attend que le nœud DOM lui-même soit retiré. Si le composant se contente de remplacer le texte dans la même balise, par exemple si le même <p> affiche d'abord « Loading... » puis « Data ready », le nœud reste en place et le test se termine par un dépassement du délai. Dans ce cas, attends le nouveau texte avec findByText.

Mon conseil : choisis findBy quand tu attends un élément, et waitFor seulement quand tu attends autre chose. Dans la prochaine leçon, tu apprendras à mocker des modules entiers et des API. Retiens : un test asynchrone est un radar patient qui écoute le signal, mais avec une limite de temps fixe.

Code de cette leçon : App.jsx
1import React, { useState, useEffect } from 'react';
2
3// API simulée : l’aperçu n’a pas de serveur, la réponse vient donc
4// d’une Promise et d’un setTimeout, comme le mock dans les tests de la leçon
5const MISSIONS = [
6  { id: 1, name: 'Apollo 11', status: 'completed' },
7  { id: 2, name: 'Artemis I', status: 'active' },
8  { id: 3, name: 'Mars 2030', status: 'planned' },
9];
10
11const STATUS_LABELS = { completed: 'terminée', active: 'active', planned: 'prévue' };
12
13function fakeApi(endpoint, delay = 1500) {
14  return new Promise((resolve, reject) => {
15    setTimeout(() => {
16      if (endpoint === '/api/missions') {
17        resolve(MISSIONS);
18      } else {
19        reject(new Error('liaison perdue avec le centre de contrôle'));
20      }
21    }, delay);
22  });
23}
24
25// Composant asynchrone : chargement, puis données ou erreur
26function MissionLoader({ endpoint }) {
27  const [data, setData] = useState(null);
28  const [error, setError] = useState(null);
29  const [attempt, setAttempt] = useState(0);
30
31  useEffect(() => {
32    let ignore = false;
33    fakeApi(endpoint)
34      .then(result => {
35        if (!ignore) setData(result);
36      })
37      .catch(err => {
38        if (!ignore) setError(err.message);
39      });
40    return () => {
41      ignore = true;
42    };
43  }, [endpoint, attempt]);
44
45  if (error) {
46    return (
47      <div className="error">
48        <p role="alert">Signal perdu : {error}</p>
49        <button
50          onClick={() => {
51            setError(null);
52            setAttempt(a => a + 1);
53          }}
54        >
55          Rétablir la liaison
56        </button>
57      </div>
58    );
59  }
60
61  if (!data) {
62    return (
63      <div className="loading" role="status">
64        <div className="spinner" />
65        <p>Réception d’un signal de l’espace lointain...</p>
66      </div>
67    );
68  }
69
70  return (
71    <div className="mission-data">
72      <h3>Missions reçues ({data.length})</h3>
73      <ul>
74        {data.map(mission => (
75          <li key={mission.id} className={`mission-${mission.status}`}>
76            <strong>{mission.name}</strong>
77            <span className="status-badge">{STATUS_LABELS[mission.status]}</span>
78          </li>
79        ))}
80      </ul>
81    </div>
82  );
83}
84
85// Recherche avec debounce : la requête ne part que 300 ms après la dernière touche
86function DebouncedSearch({ debounceMs = 300 }) {
87  const [query, setQuery] = useState('');
88  const [sent, setSent] = useState([]);
89
90  useEffect(() => {
91    if (!query) return;
92    const id = setTimeout(() => {
93      setSent(prev => [...prev, query]);
94    }, debounceMs);
95    return () => clearTimeout(id);
96  }, [query, debounceMs]);
97
98  return (
99    <div className="search">
100      <h3>Recherche avec délai</h3>
101      <label htmlFor="mission-search">Rechercher une mission</label>
102      <input
103        id="mission-search"
104        value={query}
105        onChange={e => setQuery(e.target.value)}
106        placeholder="Tape vite : Mars"
107      />
108      <p>Requêtes envoyées : {sent.length}</p>
109      {sent.length > 0 && <p className="last-query">Dernière requête : {sent[sent.length - 1]}</p>}
110    </div>
111  );
112}
113
114// Compte à rebours : setInterval, mise à jour fonctionnelle et nettoyage avec clearInterval
115function CountdownTimer({ seconds }) {
116  const [remaining, setRemaining] = useState(seconds);
117  const [running, setRunning] = useState(false);
118  const counting = running && remaining > 0;
119
120  useEffect(() => {
121    if (!counting) return;
122    const id = setInterval(() => {
123      setRemaining(r => Math.max(r - 1, 0));
124    }, 1000);
125    return () => clearInterval(id);
126  }, [counting]);
127
128  return (
129    <div className="countdown">
130      <h3>Compte à rebours du lancement</h3>
131      <div className="timer-display">
132        {remaining > 0 ? (
133          <span className="time">{remaining}</span>
134        ) : (
135          <span className="launched">Décollage !</span>
136        )}
137      </div>
138      <div className="btn-row">
139        <button onClick={() => setRunning(true)} disabled={counting || remaining === 0}>
140          {counting ? 'Décompte en cours...' : 'Lancer le compte à rebours'}
141        </button>
142        <button
143          onClick={() => {
144            setRunning(false);
145            setRemaining(seconds);
146          }}
147        >
148          Réinitialiser
149        </button>
150      </div>
151    </div>
152  );
153}
154
155export default function App() {
156  const [endpoint, setEndpoint] = useState('/api/missions');
157
158  return (
159    <div className="app">
160      <h1>Tester l’asynchrone - Signaux venus de l’espace</h1>
161      <p className="note">
162        L’aperçu montre les composants de la leçon en action. Les tests avec fake timers
163        et findBy, tu les lances dans Jest, dans ton propre projet.
164      </p>
165
166      <div className="controls">
167        <button
168          onClick={() => setEndpoint('/api/missions')}
169          className={endpoint === '/api/missions' ? 'active' : ''}
170          aria-pressed={endpoint === '/api/missions'}
171        >
172          Charger les missions
173        </button>
174        <button
175          onClick={() => setEndpoint('/api/error')}
176          className={endpoint === '/api/error' ? 'active' : ''}
177          aria-pressed={endpoint === '/api/error'}
178        >
179          Simuler une erreur
180        </button>
181      </div>
182
183      {/* key : quand la source change, le composant repart de l’état de chargement */}
184      <MissionLoader key={endpoint} endpoint={endpoint} />
185      <DebouncedSearch />
186      <CountdownTimer seconds={10} />
187    </div>
188  );
189}

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 la fonction waitFor dans React Testing Library ?

  2. 2. Quand dois-tu utiliser act() à la main dans les tests React ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Termine la recherche SearchFilter de l'atlas des objets spatiaux. ___BLANK1___ : un objet correspond à la recherche quand son nom contient le texte saisi, sans tenir compte des majuscules ni des espaces au début et à la fin du texte saisi (un texte vide correspond à tout). ___BLANK2___ : un objet correspond à la catégorie quand Tous est sélectionné (la valeur 'all') ou quand sa catégorie est égale à celle choisie. ___BLANK3___ : quand plus aucun objet ne reste après le filtrage, un message avec role="status" s'affiche à la place de la liste. Le test saisit du texte dans le champ de recherche (le rôle searchbox), choisit une catégorie dans la liste et vérifie les éléments de la liste.

  • Tri horizontal

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

  • Tri vertical

    Ordonne les étapes du test d'un composant asynchrone dans le bon ordre :

  • Éditeur de code

    Termine CountdownTimer, qui décompte les secondes jusqu'au lancement de la fusée après un clic sur Démarrer. ___BLANK1___ : chaque seconde, la fonction de mise à jour diminue la valeur actuelle de 1 (utilise le paramètre current, pas la variable left, car l'intervalle est créé une seule fois et verrait une ancienne valeur). ___BLANK2___ : l'effet renvoie une fonction de nettoyage qui appelle clearInterval(id), pour que Pause, Réinitialiser et le démontage arrêtent l'intervalle. ___BLANK3___ : elapsed est le nombre de secondes déjà écoulées (seconds moins left) ; la barre de progression l'affiche dans aria-valuenow. Le test active les faux timers et fait avancer le temps seconde par seconde.

Articles utiles