Cours JavaScript et React · Module 13: Tester React

Projet principal : une suite de tests complète pour le centre de contrôle de mission cosmique

11 min de lecture
Dans cette leçon8

Le moment est venu de récapituler tout ce qu'on a appris dans ce module ! Le Commandant Nova prépare la flotte au lancement, et le centre de contrôle de mission est un ensemble de panneaux qui doivent fonctionner sans faille. Chaque modification du code peut casser en silence le formulaire de l'équipage ou l'alarme des boucliers, et personne ne clique sur toute l'application avant chaque lancement. Ta mission est de protéger cette application avec une suite de tests complète, c'est-à-dire de réunir dans un seul projet toutes les techniques de ce module.

Description de l'application

Tu trouveras une version qui fonctionne de l'application dans l'aperçu à la fin de la leçon. Le centre de contrôle de mission se compose des éléments suivants :

1. MissionDashboard : le tableau des missions

  • Charge la liste des missions avec la fonction loadMissions qu'il reçoit en props
  • Affiche un état de chargement, une erreur avec un bouton pour réessayer, et les données chargées
  • Filtre les missions par statut
  • Affiche un résumé : le nombre total de missions et le nombre de missions actives

2. CrewManager : la gestion de l'équipage

  • Un formulaire pour ajouter des membres d'équipage avec les champs Nom, Rôle et Expérience (années)
  • La validation des données avec la fonction validateCrewMember (nom, rôle, expérience)
  • Une liste des membres avec des boutons de suppression et un filtre par rôle

3. SystemStatus : l'état des systèmes

  • Affiche les niveaux des systèmes du vaisseau sous forme de barres avec role="progressbar"
  • Lit les capteurs toutes les 10 secondes : chaque lecture fait perdre 15 points aux boucliers
  • Déclenche une alarme avec role="alert" quand un niveau descend sous 30 %

4. Hooks personnalisés

  • useMissions : chargement des missions, état de chargement et d'erreur, nouvel essai
  • useSystemHealth : lecture des systèmes à intervalle régulier
  • useCrewFilter : filtrage de l'équipage par rôle

Comment travailler avec ce projet

L'aperçu montre l'application en marche, mais il n'exécute pas Jest. Les tests de cette leçon sont écrits pour exactement ces composants : copie le code de l'aperçu dans ton propre projet, répartis-le dans des fichiers selon la structure ci-dessous et lance npm test. Configure le projet comme dans la première leçon du module : Jest avec l'environnement jsdom, une transformation du JSX et @testing-library/jest-dom dans le fichier de setup.

Tâches à réaliser

Ta suite de tests doit couvrir tous les niveaux que tu as découverts dans le module :

  1. Tests unitaires : fonctions utilitaires, validation
  2. Tests de composants : rendu, interactions, états
  3. Tests asynchrones : récupération de données, chargement, erreurs
  4. Tests de hooks : hooks personnalisés avec renderHook
  5. Mocking d'API : doublures jest.fn(), jest.mock, mocking de fetch
  6. Snapshots : composants de présentation clés

Structure des tests

Chaque composant, chaque hook et chaque fichier de fonctions utilitaires a son propre fichier de tests : quand un test échoue, tu sais tout de suite où chercher. Jest les trouve tout seul, car ils se trouvent dans le dossier __tests__ et se terminent par .test.js :

1__tests__/
2  MissionDashboard.test.js    -- tests du tableau des missions
3  CrewManager.test.js         -- tests de la gestion d'équipage
4  SystemStatus.test.js        -- tests de l'état des systèmes
5  hooks/
6    useMissions.test.js       -- tests du hook des missions
7    useCrewFilter.test.js     -- tests du hook de filtrage de l'équipage
8  utils/
9    validators.test.js        -- tests des fonctions de validation

Le dossier __tests__ se trouve dans src à côté des composants, et tu ranges les hooks et les fonctions utilitaires dans src/hooks et src/utils. Chacun de ces fichiers exporte ce que tu testes, et les imports des exemples ci-dessous supposent exactement cette organisation.

Patterns clés à appliquer

Les patterns ci-dessous couvrent toute la suite : de la fonction pure au hook et au composant, jusqu'à l'asynchrone, au temps et aux erreurs. Chaque exemple fonctionne avec le code de l'aperçu. Les noms des tests restent en anglais, comme dans tout le module.

Pattern : tester une fonction pure

La validation n'a pas besoin de React : elle reçoit un objet et renvoie une liste d'erreurs, donc tu la testes comme les fonctions de la leçon sur Jest :

1import { validateCrewMember } from '../../utils/validators';
2
3test('rejects a one-letter name and an unknown role', () => {
4  const errors = validateCrewMember({ name: 'X', role: 'cook', experience: 3 });
5
6  expect(errors).toEqual(['Le nom doit contenir au moins 2 caractères', 'Rôle inconnu']);
7});

toEqual compare le contenu du tableau, et non la référence, donc le test vérifie exactement les deux messages que l'utilisateur verra. C'est le test le moins cher et le plus rapide de toute la suite, la base de la pyramide des tests.

Pattern : tester un hook

Tu lances le hook useCrewFilter avec renderHook et tu enveloppes le changement de filtre dans act, comme dans la leçon sur les hooks :

1import { renderHook, act } from '@testing-library/react';
2import { useCrewFilter } from '../../hooks/useCrewFilter';
3
4test('filters crew by role', () => {
5  const crew = [
6    { id: 1, name: 'Astro', role: 'pilot' },
7    { id: 2, name: 'Commandant Nova', role: 'captain' },
8  ];
9  const { result } = renderHook(() => useCrewFilter(crew));
10
11  act(() => result.current.setFilter('pilot'));
12
13  expect(result.current.filtered).toEqual([crew[0]]);
14});

Tu lis le résultat dans result.current seulement après act, donc tu vois l'état après le changement de filtre : seul le pilote reste dans la liste. Le hook ne sait rien du formulaire ni des boutons, c'est pourquoi son test est court et résiste aux changements d'apparence.

Pattern : Arrange-Act-Assert

Chaque test de composant a trois parties. D'abord tu prépares la scène (Arrange), puis tu effectues une action comme un utilisateur (Act), et enfin tu vérifies le résultat (Assert). Les commentaires du test montrent ce découpage :

1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import CrewManager from '../CrewManager';
4
5test('adds new crew member', async () => {
6  // Arrange : préparer
7  const user = userEvent.setup();
8  render(<CrewManager />);
9
10  // Act : exécuter
11  await user.type(screen.getByLabelText('Nom'), 'Ra');
12  await user.selectOptions(screen.getByLabelText('Rôle'), 'capitaine');
13  await user.click(screen.getByRole('button', { name: 'Ajouter un membre' }));
14
15  // Assert : vérifier
16  expect(screen.getByText('Ra (capitaine, 0 an)')).toBeInTheDocument();
17});

getByLabelText trouve les champs, car les étiquettes leur sont reliées par htmlFor et id. L'assertion cherche toute l'entrée de la liste, et non le seul mot « capitaine », car ce mot est aussi une option de la liste déroulante et le test passerait même si l'ajout ne fonctionnait pas. Ajoute un second test pour le chemin d'erreur : le nom X doit afficher un message dans un élément avec role="alert".

Pattern : isolation des tests

Les tests d'un même fichier ne doivent pas dépendre les uns des autres. Une doublure partagée par plusieurs tests garde en mémoire les appels des précédents, donc sans nettoyage le compteur du premier test ferait échouer l'assertion du second. C'est pourquoi beforeEach efface l'historique avant chaque test :

1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import MissionDashboard from '../MissionDashboard';
4
5const missions = [{ id: 1, name: 'Apollo 11', status: 'completed', crew: 3 }];
6const loadMissions = jest.fn();
7
8beforeEach(() => {
9  // Chaque test démarre avec un historique d'appels vide
10  jest.clearAllMocks();
11});
12
13test('shows missions after loading', async () => {
14  loadMissions.mockResolvedValue(missions);
15  render(<MissionDashboard loadMissions={loadMissions} />);
16
17  expect(screen.getByRole('status')).toHaveTextContent('Chargement des missions...');
18  expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
19  expect(loadMissions).toHaveBeenCalledTimes(1);
20});

Le tableau reçoit la fonction loadMissions en props : dans le test, tu passes une doublure jest.fn() au lieu de la vraie API, et dans l'aperçu la même prop reçoit un serveur simulé. On voit d'abord l'état de chargement avec role="status", puis findByText attend que les données apparaissent. Le second test vérifie le chemin d'erreur et le nouvel essai :

1test('shows an error and retries', async () => {
2  const user = userEvent.setup();
3  loadMissions
4    .mockRejectedValueOnce(new Error('Pas de connexion avec le serveur des missions'))
5    .mockResolvedValueOnce(missions);
6  render(<MissionDashboard loadMissions={loadMissions} />);
7
8  expect(await screen.findByRole('alert')).toHaveTextContent('Pas de connexion');
9  await user.click(screen.getByRole('button', { name: 'Réessayer' }));
10
11  expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
12  expect(loadMissions).toHaveBeenCalledTimes(2);
13});

Les deux appels Once définissent les réponses successives : d'abord une panne, puis des données. Sans jest.clearAllMocks() dans beforeEach, ce test compterait aussi l'appel du test précédent et verrait 3 au lieu de 2. Quand un composant appelle lui-même fetch, ajoute aussi global.fetch = jest.fn() dans beforeEach, comme dans la leçon sur le mocking.

Pattern : le temps sous contrôle

L'alarme des boucliers n'apparaît qu'après deux lectures des capteurs, soit au bout de 20 secondes. Les fake timers font défiler ce temps en une fraction de seconde :

1import { act } from 'react';
2import { render, screen } from '@testing-library/react';
3import SystemStatus from '../SystemStatus';
4
5test('raises an alert when shields drop below 30%', () => {
6  jest.useFakeTimers();
7  render(<SystemStatus />);
8  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
9
10  // Deux lectures des capteurs : 55 % -> 40 % -> 25 %
11  act(() => {
12    jest.advanceTimersByTime(20000);
13  });
14
15  expect(screen.getByRole('progressbar', { name: 'Boucliers' })).toHaveAttribute('aria-valuenow', '25');
16  expect(screen.getByRole('alert')).toHaveTextContent('Boucliers');
17  jest.useRealTimers();
18});

queryByRole au début confirme qu'il n'y a pas encore d'alarme, car getByRole lancerait une erreur à cet endroit. Avancer l'horloge modifie l'état en dehors des outils de RTL, c'est pourquoi on le place dans act. Tu trouves la barre par le rôle progressbar et le nom donné par aria-label, et tu lis sa valeur dans aria-valuenow.

Pattern : tester une Error Boundary

Une Error Boundary est un composant qui attrape une erreur lancée pendant le rendu de ses enfants et affiche un message de secours au lieu d'un écran vide. React n'a pas de hook pour cela, donc tu écris la boundary sous forme de composant de classe avec une méthode static getDerivedStateFromError, ou tu en prends une toute faite dans la bibliothèque react-error-boundary :

1import { Component } from 'react';
2
3class ErrorBoundary extends Component {
4  state = { hasError: false };
5
6  static getDerivedStateFromError() {
7    return { hasError: true };
8  }
9
10  render() {
11    return this.state.hasError ? this.props.fallback : this.props.children;
12  }
13}

getDerivedStateFromError passe hasError à vrai, et render affiche alors l'élément de la prop fallback. Le test donne à la boundary un composant qui lance toujours une erreur :

1test('shows error boundary on crash', () => {
2  const ThrowError = () => {
3    throw new Error('Kaboom!');
4  };
5
6  // React 19 signale une erreur attrapée par une boundary avec console.error
7  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
8
9  render(
10    <ErrorBoundary fallback={<p>Une erreur est survenue</p>}>
11      <ThrowError />
12    </ErrorBoundary>
13  );
14
15  expect(screen.getByText('Une erreur est survenue')).toBeInTheDocument();
16  spy.mockRestore();
17});

Dans React 19, une erreur attrapée par une boundary part dans console.error : sans l'espion, le test passe, mais il encombre le résultat d'une pile d'appels en rouge. mockRestore() à la fin rend à la console sa vraie fonction, pour que les tests suivants voient de nouveau les avertissements.

Bonnes pratiques de test

  1. Tester le comportement, pas l'implémentation : vérifier ce que l'utilisateur voit
  2. Un concept par test : chaque test vérifie une seule chose
  3. Des noms de tests lisibles : it('displays error when mission not found')
  4. DRY dans les tests avec modération : la duplication dans les tests vaut mieux que des abstractions trop compliquées
  5. Tester les chemins de succès et d'erreur : le scénario où tout se passe bien et les cas limites
  6. Ne pas tester le framework : ne teste pas si useState fonctionne, teste ta logique

Ce que tu rends à ton mentor

Après cette leçon, un projet pour ton mentor t'attend. Construis ta propre application React ou enrichis le centre de contrôle de mission, et prépare pour elle une suite de tests qui couvre :

  • un tableau avec des données venant d'une API (une doublure à la place du vrai serveur dans les tests), avec la gestion du chargement et des erreurs
  • un formulaire avec validation, vérifié avec userEvent et des messages avec role="alert"
  • au moins un hook personnalisé testé avec renderHook
  • des tests unitaires, des tests d'intégration de plusieurs composants et un snapshot d'un composant de présentation simple
  • les chemins de succès et les cas limites

Pousse le projet et ses tests sur GitHub, puis envoie le lien du dépôt. Ton mentor lancera npm test, alors vérifie que tous les tests passent sur une copie toute neuve du projet.

Examine l'aperçu

Ci-dessous, tu trouveras le centre de contrôle de mission en marche. Clique partout et compare avec les tests de la leçon :

  • clique sur Simuler une panne du serveur : le tableau affiche une erreur et un bouton Réessayer, comme dans le test du chemin d'erreur, et après un clic sur Rétablir le serveur il recharge les missions
  • ajoute un membre d'équipage avec un nom d'une seule lettre et regarde les messages de validation
  • attends 20 secondes que les boucliers descendent sous 30 % et que l'alarme apparaisse

Mon conseil : avant d'écrire un test, déroule le scénario dans l'aperçu et note-le en trois étapes : Arrange, Act et Assert. Le test s'écrit alors presque tout seul. Retiens : une bonne suite de tests est une vérification avant le lancement qui inspecte tout le vaisseau à chaque modification du code, avant que tu partes dans le vrai cosmos.

Code de cette leçon : App.jsx
1import React, { useState, useEffect, useCallback } from 'react';
2
3// ===== API simulée =====
4// L’aperçu n’a pas de serveur, une Promise avec setTimeout simule donc la réponse.
5// Dans les tests de la leçon, MissionDashboard reçoit à la place un mock jest.fn().
6const MISSIONS = [
7  { id: 1, name: 'Apollo 11', status: 'completed', crew: 3 },
8  { id: 2, name: 'Artemis II', status: 'active', crew: 4 },
9  { id: 3, name: 'Europa Clipper', status: 'active', crew: 0 },
10  { id: 4, name: 'Mars Sample Return', status: 'planned', crew: 0 },
11];
12
13function fetchMissions(serverDown) {
14  return new Promise((resolve, reject) => {
15    setTimeout(() => {
16      if (serverDown) reject(new Error('Pas de connexion avec le serveur des missions'));
17      else resolve(MISSIONS);
18    }, 800);
19  });
20}
21
22// ===== Fonctions utilitaires =====
23const ROLES = { captain: 'capitaine', pilot: 'pilote', engineer: 'ingénieur', scientist: 'scientifique' };
24const FILTER_LABELS = { all: 'toutes', active: 'actives', completed: 'terminées', planned: 'prévues' };
25const STATUS_LABELS = { active: 'active', completed: 'terminée', planned: 'prévue' };
26const SYSTEM_LABELS = { engine: 'Moteur', shields: 'Boucliers', navigation: 'Navigation', lifeSupport: 'Support vital' };
27
28function validateCrewMember(member) {
29  const errors = [];
30  if (!member.name || member.name.trim().length < 2) {
31    errors.push('Le nom doit contenir au moins 2 caractères');
32  }
33  if (!Object.keys(ROLES).includes(member.role)) {
34    errors.push('Rôle inconnu');
35  }
36  if (member.experience < 0 || member.experience > 50) {
37    errors.push('L’expérience doit être comprise entre 0 et 50 ans');
38  }
39  return errors;
40}
41
42function yearsLabel(n) {
43  return n <= 1 ? `${n} an` : `${n} ans`;
44}
45
46// ===== Hooks personnalisés =====
47function useMissions(loadMissions) {
48  const [missions, setMissions] = useState([]);
49  const [loading, setLoading] = useState(true);
50  const [error, setError] = useState(null);
51  const [attempt, setAttempt] = useState(0);
52
53  useEffect(() => {
54    let ignore = false;
55    loadMissions()
56      .then(data => {
57        if (!ignore) setMissions(data);
58      })
59      .catch(err => {
60        if (!ignore) setError(err.message);
61      })
62      .finally(() => {
63        if (!ignore) setLoading(false);
64      });
65    return () => {
66      ignore = true;
67    };
68  }, [loadMissions, attempt]);
69
70  // Nouvel essai : on affiche tout de suite le chargement et on recharge
71  const refetch = useCallback(() => {
72    setLoading(true);
73    setError(null);
74    setAttempt(a => a + 1);
75  }, []);
76  return { missions, loading, error, refetch };
77}
78
79function useCrewFilter(crew) {
80  const [filter, setFilter] = useState('all');
81  const filtered = filter === 'all' ? crew : crew.filter(member => member.role === filter);
82  return { filtered, filter, setFilter };
83}
84
85function useSystemHealth(intervalMs = 10000) {
86  const [systems, setSystems] = useState({ engine: 98, shields: 55, navigation: 100, lifeSupport: 92 });
87
88  useEffect(() => {
89    // À chaque lecture des capteurs, les boucliers perdent 15 points
90    const id = setInterval(() => {
91      setSystems(s => ({ ...s, shields: Math.max(s.shields - 15, 0) }));
92    }, intervalMs);
93    return () => clearInterval(id);
94  }, [intervalMs]);
95
96  const repairShields = () => setSystems(s => ({ ...s, shields: 100 }));
97  return { systems, repairShields };
98}
99
100// ===== Composants =====
101function MissionDashboard({ loadMissions }) {
102  const { missions, loading, error, refetch } = useMissions(loadMissions);
103  const [statusFilter, setStatusFilter] = useState('all');
104
105  if (loading) {
106    return (
107      <section className="panel">
108        <h2>Tableau des missions</h2>
109        <p className="loading" role="status">Chargement des missions...</p>
110      </section>
111    );
112  }
113
114  if (error) {
115    return (
116      <section className="panel">
117        <h2>Tableau des missions</h2>
118        <p className="error" role="alert">Erreur : {error}</p>
119        <button className="refresh-btn" onClick={refetch}>Réessayer</button>
120      </section>
121    );
122  }
123
124  const visible = statusFilter === 'all' ? missions : missions.filter(m => m.status === statusFilter);
125
126  return (
127    <section className="panel">
128      <h2>Tableau des missions</h2>
129      <div className="stats">
130        <div className="stat">Total : {missions.length}</div>
131        <div className="stat">Actives : {missions.filter(m => m.status === 'active').length}</div>
132      </div>
133
134      <div className="filters">
135        {Object.keys(FILTER_LABELS).map(s => (
136          <button key={s} aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}>
137            {FILTER_LABELS[s]}
138          </button>
139        ))}
140      </div>
141
142      <ul className="mission-list" aria-label="Missions">
143        {visible.map(m => (
144          <li key={m.id}>
145            <strong>{m.name}</strong>
146            <span className="badge">{STATUS_LABELS[m.status]}</span>
147            <span className="crew-count">équipage : {m.crew}</span>
148          </li>
149        ))}
150      </ul>
151
152      <button className="refresh-btn" onClick={refetch}>Actualiser</button>
153    </section>
154  );
155}
156
157function CrewManager() {
158  const [crew, setCrew] = useState([
159    { id: 1, name: 'Commandant Nova', role: 'captain', experience: 15 },
160    { id: 2, name: 'Astro', role: 'pilot', experience: 6 },
161    { id: 3, name: 'Ingénieur Bolt', role: 'engineer', experience: 8 },
162  ]);
163  const [formData, setFormData] = useState({ name: '', role: 'pilot', experience: 0 });
164  const [errors, setErrors] = useState([]);
165  const { filtered, filter, setFilter } = useCrewFilter(crew);
166
167  const handleSubmit = (e) => {
168    e.preventDefault();
169    const validationErrors = validateCrewMember(formData);
170    if (validationErrors.length > 0) {
171      setErrors(validationErrors);
172      return;
173    }
174    setCrew(prev => [...prev, { ...formData, name: formData.name.trim(), id: Date.now() }]);
175    setFormData({ name: '', role: 'pilot', experience: 0 });
176    setErrors([]);
177  };
178
179  const handleRemove = (id) => {
180    setCrew(prev => prev.filter(m => m.id !== id));
181  };
182
183  return (
184    <section className="panel">
185      <h2>Gestion de l’équipage</h2>
186
187      <form className="crew-form" onSubmit={handleSubmit}>
188        <div className="field">
189          <label htmlFor="crew-name">Nom</label>
190          <input
191            id="crew-name"
192            value={formData.name}
193            onChange={e => setFormData(d => ({ ...d, name: e.target.value }))}
194          />
195        </div>
196        <div className="field">
197          <label htmlFor="crew-role">Rôle</label>
198          <select
199            id="crew-role"
200            value={formData.role}
201            onChange={e => setFormData(d => ({ ...d, role: e.target.value }))}
202          >
203            {Object.entries(ROLES).map(([value, label]) => (
204              <option key={value} value={value}>{label}</option>
205            ))}
206          </select>
207        </div>
208        <div className="field">
209          <label htmlFor="crew-exp">Expérience (années)</label>
210          <input
211            id="crew-exp"
212            type="number"
213            value={formData.experience}
214            onChange={e => setFormData(d => ({ ...d, experience: Number(e.target.value) }))}
215          />
216        </div>
217        <button type="submit">Ajouter un membre</button>
218      </form>
219
220      {errors.length > 0 && (
221        <div className="error-list" role="alert">
222          {errors.map(message => <p key={message}>{message}</p>)}
223        </div>
224      )}
225
226      <div className="filters">
227        {['all', ...Object.keys(ROLES)].map(r => (
228          <button key={r} aria-pressed={filter === r} onClick={() => setFilter(r)}>
229            {r === 'all' ? 'tous' : ROLES[r]}
230          </button>
231        ))}
232      </div>
233
234      <ul className="crew-list" aria-label="Équipage">
235        {filtered.map(m => (
236          <li key={m.id}>
237            <span>{m.name} ({ROLES[m.role]}, {yearsLabel(m.experience)})</span>
238            <button className="remove" aria-label={`Retirer : ${m.name}`} onClick={() => handleRemove(m.id)}>
239              Retirer
240            </button>
241          </li>
242        ))}
243      </ul>
244    </section>
245  );
246}
247
248function SystemStatus() {
249  const { systems, repairShields } = useSystemHealth(10000);
250  const critical = Object.keys(systems).filter(key => systems[key] < 30);
251
252  const getStatusColor = (level) => {
253    if (level > 80) return '#4caf50';
254    if (level > 50) return '#ff9800';
255    return '#f44336';
256  };
257
258  return (
259    <section className="panel">
260      <h2>État des systèmes</h2>
261      <p className="hint">Lecture des capteurs toutes les 10 s : les boucliers perdent 15 points.</p>
262      <div className="system-grid">
263        {Object.entries(systems).map(([key, value]) => (
264          <div key={key} className="system-item">
265            <span className="system-name">{SYSTEM_LABELS[key]}</span>
266            <div
267              className="progress-bar"
268              role="progressbar"
269              aria-label={SYSTEM_LABELS[key]}
270              aria-valuenow={value}
271              aria-valuemin={0}
272              aria-valuemax={100}
273            >
274              <div className="progress-fill" style={{ width: `${value}%`, background: getStatusColor(value) }} />
275            </div>
276            <span className="system-value">{value}%</span>
277          </div>
278        ))}
279      </div>
280      {critical.length > 0 && (
281        <div className="system-alert">
282          <p role="alert">Niveau critique : {critical.map(key => SYSTEM_LABELS[key]).join(', ')}</p>
283          <button onClick={repairShields}>Réparer les boucliers</button>
284        </div>
285      )}
286    </section>
287  );
288}
289
290export default function App() {
291  const [serverDown, setServerDown] = useState(false);
292  const loadMissions = useCallback(() => fetchMissions(serverDown), [serverDown]);
293
294  return (
295    <div className="app">
296      <h1>Centre de contrôle de mission cosmique</h1>
297      <p className="subtitle">
298        Projet principal : une application qui fonctionne et que vérifient les tests de la leçon.
299        L’aperçu n’exécute pas Jest, tu lances les tests dans ton propre projet.
300      </p>
301      <button className="sim-btn" onClick={() => setServerDown(d => !d)} aria-pressed={serverDown}>
302        {serverDown ? 'Rétablir le serveur' : 'Simuler une panne du serveur'}
303      </button>
304      {/* key : après le changement de serveur, le tableau repart de l’état de chargement */}
305      <MissionDashboard key={serverDown ? 'down' : 'up'} loadMissions={loadMissions} />
306      <CrewManager />
307      <SystemStatus />
308    </div>
309  );
310}

Tu as repéré une erreur dans cette leçon ?

Articles utiles