Cours JavaScript et React · Module 13: Tester React

Snapshot testing : la photographie d'une constellation

9 min de lecture
Dans cette leçon8

Imagine qu'après chaque modification du code tu doives vérifier à la main que le badge de statut d'une mission a toujours les mêmes classes, le même texte et la même structure. Avec des dizaines de composants, personne ne le ferait. Le snapshot testing le fait pour toi : il « photographie » le résultat du rendu d'un composant et le compare avec une référence enregistrée. C'est comme prendre une photo d'une constellation d'étoiles : si une étoile a bougé la prochaine fois que tu regardes, le test le détectera.

Comment fonctionnent les snapshots ?

Le mécanisme est simple. Lors de la première exécution du test, Jest rend le composant et enregistre le HTML obtenu dans un fichier .snap situé dans le dossier __snapshots__, à côté du fichier de test. Lors des exécutions suivantes, Jest refait le rendu du composant et compare le résultat avec la référence enregistrée. Si quelque chose a changé, le test échoue. C'est comme comparer deux photos d'une constellation prises à des moments différents : si les étoiles ont bougé, tu le remarques immédiatement.

Voici un exemple de base. On rend le composant avec render de React Testing Library et on passe à toMatchSnapshot() le premier élément du conteneur :

1import { render } from '@testing-library/react';
2import StatusBadge from './StatusBadge';
3
4test('renders active status badge', () => {
5  const { container } = render(<StatusBadge status="active" />);
6  expect(container.firstChild).toMatchSnapshot();
7});

container est l'élément div dans lequel RTL insère le composant, et container.firstChild est la racine du composant lui-même, ici la balise span. Tu peux aussi écrire expect(asFragment()).toMatchSnapshot() : la fonction asFragment du résultat de render renvoie tout le fragment rendu, enveloppé dans <DocumentFragment>.

Dans les tutoriels plus anciens, tu verras des snapshots faits avec react-test-renderer (renderer.create(...).toJSON()). Dans React 19, ce paquet est déprécié et affiche un avertissement, et l'équipe React recommande React Testing Library à la place. C'est pourquoi, dans ce cours, un snapshot vient toujours du résultat de render.

Fichier snapshot généré (.snap)

Après la première exécution, un fichier qui porte le nom du fichier de test avec l'extension .snap apparaît dans le dossier __snapshots__. Jest y enregistre chaque snapshot sous le nom du test et un numéro d'ordre :

1// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
3exports[`renders active status badge 1`] = `
4<span
5  class="badge badge-active"
6>
7  Active
8</span>
9`;

Tu ajoutes le fichier .snap au dépôt avec le test, car c'est la référence à laquelle chaque exécution suivante sera comparée. Remarque qu'il contient du HTML final avec l'attribut class, et non du JSX avec className.

Snapshots inline

Au lieu d'un fichier séparé, le snapshot peut être enregistré directement dans le test. Prenons une jauge de carburant qui construit son libellé en un seul texte :

1function FuelDisplay({ level }) {
2  return (
3    <div className="fuel-display">
4      <span>{`Fuel: ${level}%`}</span>
5    </div>
6  );
7}

On construit le libellé avec un gabarit, car l'écriture Fuel: {level}% donnerait trois lignes séparées dans le snapshot : chaque fragment de texte en JSX est un nœud distinct. Le test avec toMatchInlineSnapshot ressemble à ceci :

1test('renders fuel display', () => {
2  const { container } = render(<FuelDisplay level={75} />);
3  expect(container.firstChild).toMatchInlineSnapshot(`
4    <div
5      class="fuel-display"
6    >
7      <span>
8        Fuel: 75%
9      </span>
10    </div>
11  `);
12});

À la première exécution, il suffit d'écrire toMatchInlineSnapshot() sans argument, et Jest inscrit la référence dans le fichier de test à ta place. Un snapshot inline est pratique pour les petits composants, car la référence se trouve juste à côté du test.

Mettre à jour les snapshots

Quand tu modifies intentionnellement un composant, l'ancienne référence ne correspond plus et le test échoue. Tu mets alors les snapshots à jour avec l'option --updateSnapshot (en version courte -u). Le double tiret transmet cette option à Jest à travers npm test :

1# Mettre à jour tous les snapshots
2npm test -- --updateSnapshot
3
4# Ou en version courte
5npm test -- -u

Avant de mettre à jour, lis la différence que Jest a affichée. La mise à jour écrase la référence sans rien demander, donc si le changement était un bug, tu viens de le valider.

Quand utiliser les snapshots ?

Bons cas d'usage

  • Composants de présentation : composants simples sans logique
  • L'arbre JSX résultant : vérifier la structure HTML
  • Données sérialisables : objets, tableaux, JSON

Une carte de planète est le candidat idéal : elle reçoit des props et se contente de les afficher, donc le même ensemble de props donne toujours le même HTML :

1// Bon snapshot : composant simple
2test('renders planet card', () => {
3  const { container } = render(
4    <PlanetCard name="Mars" distance="225M km" color="red" />
5  );
6  expect(container.firstChild).toMatchSnapshot();
7});

Mauvais cas d'usage

  • Composants avec une logique dynamique : le snapshot ne teste pas le comportement
  • Composants volumineux : les snapshots deviennent difficiles à relire
  • Composants avec dates/IDs aléatoires : le snapshot change tout le temps

L'erreur la plus fréquente est le snapshot d'un écran entier. Le fichier compte alors des centaines de lignes, et chaque petite modification oblige quelqu'un à les relire :

1// MAUVAIS snapshot : trop volumineux, trop dynamique
2test('renders entire dashboard', () => {
3  const { container } = render(<Dashboard />); // 500 lignes de HTML !
4  expect(container).toMatchSnapshot(); // Personne ne lira ça !
5});

Un tel test échoue à chaque modification sans dire ce qui est cassé, alors on se met vite à le mettre à jour sans le lire.

Snapshot ou assertions ciblées ?

C'est une comparaison fondamentale que tout développeur React devrait comprendre. Un snapshot vérifie la structure entière d'un composant : chaque balise, chaque classe, chaque attribut. Une assertion ciblée vérifie un comportement précis : si un texte est visible, si un bouton est actif, si un élément a la bonne classe. La différence revient à photographier tout le panneau de contrôle plutôt que de vérifier si un voyant précis est vert.

1// Snapshot : vérifie la structure ENTIÈRE
2expect(container.firstChild).toMatchSnapshot();
3
4// Assertion ciblée : vérifie un comportement PRÉCIS (recommandé !)
5expect(screen.getByText('Active')).toHaveClass('badge-active');
6expect(screen.getByRole('button')).toBeEnabled();

Une assertion ciblée exprime aussi directement ce qu'un snapshot n'expliquerait pas. Dans un tableau triable, l'en-tête de colonne indique aux lecteurs d'écran le sens du tri avec l'attribut aria-sort, et le test le vérifie en une seule instruction :

1expect(screen.getByRole('columnheader', { name: /nom/i }))
2  .toHaveAttribute('aria-sort', 'ascending');

Un snapshot enregistrerait aussi aria-sort, mais il se perdrait parmi des centaines d'autres attributs. Une assertion ciblée nomme le comportement que tu protèges, et son message d'échec dit tout de suite ce qui est cassé.

Règle : privilégie les assertions ciblées plutôt que les snapshots. Les snapshots deviennent facilement « invisibles » : les développeurs les mettent souvent à jour automatiquement avec la commande -u sans vérifier ce qui a changé. Les assertions ciblées, en revanche, exigent une vérification consciente d'un comportement précis. Les snapshots sont bons comme protection supplémentaire contre les changements inattendus de la structure HTML, mais ne devraient pas être la principale façon de tester. Souviens-toi aussi qu'un snapshot du DOM ne voit pas les styles CSS : seuls les tests qui comparent des captures d'écran repèrent les changements d'apparence.

Des données qui changent à chaque exécution

Des identifiants aléatoires et la date du jour rendent le snapshot différent à chaque exécution. Pour les objets, Jest propose les property matchers : à la place des champs variables, tu donnes un type au lieu d'une valeur. La fonction createMissionLog crée une entrée de journal avec un id aléatoire et une date de création :

1test('creates a mission log', () => {
2  const log = createMissionLog('Artemis III');
3
4  expect(log).toMatchSnapshot({
5    id: expect.any(String),
6    createdAt: expect.any(Date),
7  });
8});

Au lieu de valeurs concrètes, le fichier .snap contiendra Any<String> et Any<Date>, tandis que les autres champs, comme le nom de la mission, sont toujours comparés exactement. Dans un composant qui affiche la date du jour, fige l'horloge avec jest.useFakeTimers() et jest.setSystemTime(new Date('2030-07-20')), pour que chaque exécution voie le même jour.

Le second outil, ce sont les sérialiseurs, qui décident comment une valeur est écrite dans le fichier .snap. Celui-ci remplace les numéros de vaisseau dans les chaînes par un marqueur fixe :

1// Dans le fichier jest.setup.js
2expect.addSnapshotSerializer({
3  test: (val) => typeof val === 'string' && /^ship-\d+$/.test(val),
4  print: () => '"ship-<id>"',
5});

La fonction test choisit les valeurs dont le sérialiseur s'occupe, et print renvoie leur écriture. Méfie-toi d'un test trop large : un sérialiseur qui prend en charge chaque élément ayant une classe CSS écrit une seule ligne au lieu de toute la structure du composant, et le snapshot ne surveille plus rien.

Snapshot d'une partie choisie

Tu n'es pas obligé de photographier tout le composant. Quand tu surveilles une seule partie, par exemple la liste des liens de la navigation, fais un snapshot de cet élément seul :

1test('renders navigation with correct structure', () => {
2  const routes = [
3    { path: '/mars', label: 'Mars' },
4    { path: '/europa', label: 'Europa' },
5  ];
6  render(<SpaceNavigation routes={routes} />);
7
8  // Snapshot d'une seule partie du composant
9  expect(screen.getByRole('navigation')).toMatchSnapshot();
10});

getByRole('navigation') trouve l'élément <nav> comme le voit un lecteur d'écran, donc le snapshot ne couvre que le menu. Une référence plus petite se relit plus facilement, et les changements dans le reste du composant ne la touchent pas.

Résumé : quand un snapshot, quand pas ?

SituationSnapshotAssertion ciblée
Composant UI simpleOuiOui
Logique métierNonOui
Interactions utilisateurNonOui
Formatage des donnéesOuiOui
Composant volumineuxNonOui
Changement involontaire de la structure HTMLOuiNon

Regarde aussi le chemin parcouru dans ce module. On a commencé par le test d'une fonction pure, puis sont venus le test du rendu d'un composant, le test des interactions de l'utilisateur et le test d'un composant asynchrone avec une API mockée. Tout en haut se trouve le test d'intégration, qui vérifie plusieurs composants à la fois : tu le construiras dans le projet principal. Le snapshot complète chacun de ces niveaux, il ne les remplace pas.

Mon conseil : traite un snapshot comme une photo de contrôle, pas comme une preuve d'exactitude, et lis chaque différence avant de taper -u. Retiens : le snapshot veille à ce que la constellation ne bouge pas, mais ce sont les assertions ciblées qui te disent si le vaisseau vole dans la bonne direction.

Code de cette leçon : App.jsx
1import React, { useState, useRef, useLayoutEffect } from 'react';
2
3// Composants de présentation : ils reçoivent des props et se contentent de les afficher,
4// c’est pourquoi ils se prêtent bien aux snapshots
5
6const STATUS_LABELS = {
7  active: 'active',
8  completed: 'terminée',
9  planned: 'prévue',
10  critical: 'critique',
11};
12
13function StatusBadge({ status, outlined = false }) {
14  const className = `status-badge badge-${status}${outlined ? ' outlined' : ''}`;
15  return <span className={className}>{STATUS_LABELS[status].toUpperCase()}</span>;
16}
17
18function PlanetCard({ name, distance, diameter, moons }) {
19  return (
20    <div className="planet-card">
21      <div className="planet-icon">{name[0]}</div>
22      <div className="planet-info">
23        <h3>{name}</h3>
24        <div className="planet-stats">
25          <span>Distance : {distance}</span>
26          <span>Diamètre : {diameter}</span>
27          <span>Lunes : {moons}</span>
28        </div>
29      </div>
30    </div>
31  );
32}
33
34function CrewTable({ members }) {
35  return (
36    <table className="crew-table">
37      <thead>
38        <tr>
39          <th>Nom</th>
40          <th>Rôle</th>
41          <th>Statut de la mission</th>
42        </tr>
43      </thead>
44      <tbody>
45        {members.map(m => (
46          <tr key={m.name}>
47            <td>{m.name}</td>
48            <td>{m.role}</td>
49            <td><StatusBadge status={m.status} /></td>
50          </tr>
51        ))}
52      </tbody>
53    </table>
54  );
55}
56
57// Laboratoire : la première exécution enregistre la référence, les suivantes la comparent
58function SnapshotLab() {
59  const [outlined, setOutlined] = useState(false);
60  const [snap, setSnap] = useState(null);
61  const [markup, setMarkup] = useState('');
62  const [result, setResult] = useState('Le test n’a pas encore été lancé.');
63  const previewRef = useRef(null);
64
65  useLayoutEffect(() => {
66    setMarkup(previewRef.current.innerHTML);
67  }, [outlined]);
68
69  const runTest = () => {
70    if (snap === null) {
71      setSnap(markup);
72      setResult('Première exécution : Jest a enregistré la référence dans le fichier .snap.');
73    } else if (snap === markup) {
74      setResult('PASS : le résultat correspond au snapshot enregistré.');
75    } else {
76      setResult('FAIL : le résultat diffère du snapshot. Bug ou changement voulu ?');
77    }
78  };
79
80  const updateSnapshot = () => {
81    setSnap(markup);
82    setResult('Snapshot mis à jour (npm test -- -u).');
83  };
84
85  return (
86    <div className="section">
87      <h2>Laboratoire des snapshots</h2>
88      <div ref={previewRef} className="lab-preview">
89        <StatusBadge status="active" outlined={outlined} />
90      </div>
91      <pre className="markup">{markup}</pre>
92      <div className="btn-row">
93        <button onClick={runTest}>Lancer le test</button>
94        <button onClick={() => setOutlined(o => !o)}>Modifier le code du composant</button>
95        <button onClick={updateSnapshot} disabled={snap === null}>Mettre à jour le snapshot</button>
96      </div>
97      <p className={result.startsWith('FAIL') ? 'lab-result fail' : 'lab-result'} role="status">
98        {result}
99      </p>
100    </div>
101  );
102}
103
104export default function App() {
105  const planets = [
106    { name: 'Mars', distance: '225 millions de km', diameter: '6 779 km', moons: 2 },
107    { name: 'Jupiter', distance: '778 millions de km', diameter: '139 820 km', moons: 95 },
108    { name: 'Saturne', distance: '1,4 milliard de km', diameter: '116 460 km', moons: 146 },
109  ];
110
111  const crew = [
112    { name: 'Commandant Nova', role: 'capitaine', status: 'active' },
113    { name: 'Astro', role: 'pilote', status: 'planned' },
114    { name: 'Ingénieur Bolt', role: 'ingénieur', status: 'completed' },
115  ];
116
117  return (
118    <div className="app">
119      <h1>Snapshot testing</h1>
120      <p className="subtitle">La photo d’une constellation : comparer la structure du composant</p>
121
122      <SnapshotLab />
123
124      <div className="section">
125        <h2>Badges de statut</h2>
126        <div className="badge-row">
127          <StatusBadge status="active" />
128          <StatusBadge status="completed" />
129          <StatusBadge status="planned" />
130          <StatusBadge status="critical" />
131        </div>
132      </div>
133
134      <div className="section">
135        <h2>Cartes des planètes</h2>
136        {planets.map(p => <PlanetCard key={p.name} {...p} />)}
137      </div>
138
139      <div className="section">
140        <h2>Tableau de l’équipage</h2>
141        <CrewTable members={crew} />
142      </div>
143
144      <div className="hint">
145        Le snapshot convient aux composants de présentation simples comme ceux ci-dessus.
146        Vérifie le comportement (clics, données de l’API) avec des assertions ciblées.
147      </div>
148    </div>
149  );
150}

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. Quand le snapshot testing est-il le MEILLEUR choix ?

  2. 2. Quelle est la philosophie principale de React Testing Library ?

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes du cycle de vie d'un snapshot dans le bon ordre :

  • Tri horizontal

    Ordonne les éléments de la syntaxe d'un test de snapshot dans le bon ordre :

  • Clique dans l'ordre

    Ordonne les éléments de la commande de mise à jour des snapshots dans le bon ordre :

  • Tri vertical

    Ordonne les sélecteurs RTL de la priorité la plus haute (la meilleure) à la plus basse :

  • Tri horizontal

    Ordonne les éléments de la syntaxe waitFor avec une assertion asynchrone dans le bon ordre :

  • Clique dans l'ordre

    Ordonne les éléments de la syntaxe du mocking des timers Jest dans le bon ordre :

  • Tri vertical

    Ordonne les types de tests du plus simple au plus complexe :

  • Clique dans l'ordre

    Ordonne les éléments de la syntaxe act() avec renderHook dans le bon ordre :

  • Éditeur de code

    Termine le tableau DataTable du catalogue des planètes. Les en-têtes de colonnes sont des boutons dans th, et th a aria-sort ('ascending', 'descending' ou 'none'). ___BLANK1___ : avant de trier, fais une copie du tableau rows, car sort() modifie le tableau sur lequel tu l'appelles, et il ne faut pas modifier les props. ___BLANK2___ : un deuxième clic sur le même en-tête passe le sens à 'descending' (le suivant revient à 'ascending'). ___BLANK3___ : quand le tableau rows est vide, un message avec role="status" s'affiche à la place du tableau. Le test clique sur les en-têtes, lit l'ordre des cellules des lignes (les rôles row et cell) et vérifie aria-sort.

  • Tri vertical

    Ordonne les étapes de la configuration d'un environnement de test React dans le bon ordre :

Articles utiles