Chapitre 1321 minIntermédiaire
Tester des composants React avec Vitest et Testing Library
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- configurer Vitest, jsdom et Testing Library dans un projet Vite ;
- trouver les éléments comme un utilisateur, par leur rôle et leur étiquette ;
- simuler la saisie et les clics avec user-event ;
- remplacer l'API par des fonctions simulées ;
- écrire un test de non-régression pour un bogue corrigé.
3 exercices corrigés
Tester un composant React, c'est l'afficher dans un faux navigateur, agir dessus comme un utilisateur (taper, cliquer) et vérifier ce qui apparaît à l'écran. On utilise Vitest, le lanceur de tests intégré à Vite, jsdom, qui simule le DOM dans Node.js, et React Testing Library, qui affiche les composants et les interroge comme le ferait un utilisateur. Ce chapitre installe ces outils et teste AppliFrais : fonctions de mise en forme, badge, formulaire avec appel d'API simulé, route protégée.
Pourquoi tester un front
Les principes des tests (unitaires, d'intégration, de bout en bout, non-régression, pyramide des tests) sont expliqués dans le cours Tests (les types de tests et la pyramide). Pour un front React, ils servent à vérifier que :
- les fonctions pures donnent le bon résultat (
formatMois('2026-10')donne « octobre 2026 ») ; - un composant affiche ce qu'il faut selon ses props et son état ;
- une interaction produit le bon effet (le formulaire envoie les bonnes données, l'erreur de l'API s'affiche) ;
- une modification ne casse rien ailleurs : c'est le test de non-régression, que l'E6 demande explicitement (« les tests de non-régression sont faits »).
| Niveau | Outil | Exemple dans AppliFrais |
|---|---|---|
| Unitaire | Vitest | formatMontant(23.5) renvoie « 23,50 € » |
| Composant | Vitest + Testing Library + jsdom | Le formulaire affiche l'erreur 409 renvoyée par l'API simulée |
| De bout en bout | Playwright, dans un vrai navigateur, avec la vraie API | Se connecter, ouvrir la fiche 12, ajouter un frais |
Ce chapitre traite les deux premiers niveaux, qui s'exécutent en quelques secondes à chaque modification.
Installer et configurer
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
| Paquet | Rôle |
|---|---|
vitest | Le lanceur de tests ; il réutilise la configuration de Vite (TypeScript, JSX, alias) |
jsdom | Un DOM simulé en JavaScript : document, window, les événements |
@testing-library/react | render pour afficher un composant, screen pour l'interroger |
@testing-library/dom | Le cœur de Testing Library, à installer explicitement avec la version 16 de @testing-library/react |
@testing-library/user-event | Simule un utilisateur : frappe, clic, tabulation |
@testing-library/jest-dom | Des vérifications lisibles : toBeInTheDocument(), toHaveValue(), toBeDisabled() |
On complète vite.config.ts avec une section test :
// vite.config.ts
/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
proxy: { '/v1': 'http://localhost:3000' },
},
test: {
environment: 'jsdom', // un DOM simulé pour les composants
setupFiles: ['./src/test/setup.ts'], // exécuté avant chaque fichier de test
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup(); // retire du DOM ce que le test a affiché
});
La première ligne ajoute les vérifications de jest-dom à expect, avec leurs types. Le cleanup après chaque test évite qu'un composant affiché par un test reste visible pour le suivant (Testing Library ne le fait automatiquement que si les fonctions de test sont globales, ce qui n'est pas le cas ici). Enfin, dans package.json :
"scripts": {
"test": "vitest",
"test:ci": "vitest run"
}
npm test lance Vitest en mode surveillance : il relance les tests concernés à chaque enregistrement. npm run test:ci les exécute une fois, pour la chaîne d'intégration continue. Un fichier de test se nomme comme le fichier testé, avec .test : format.test.ts, BadgeEtat.test.tsx, rangé à côté.
Premier test : une fonction pure
// src/format.test.ts
import { describe, expect, it } from 'vitest';
import { formatDate, formatMois, formatMontant } from './format';
describe('format', () => {
it('formate un mois', () => {
expect(formatMois('2026-10')).toBe('octobre 2026');
});
it('formate une date sans décalage', () => {
expect(formatDate('2026-10-14')).toBe('14/10/2026');
});
it('formate un montant en euros', () => {
expect(formatMontant(23.5)).toBe('23,50 €');
});
});
describe regroupe des tests, it (ou test) en déclare un, expect(valeur).toBe(attendu) vérifie. Le est l'espace insécable que Intl.NumberFormat place avant le symbole « € » : un test qui écrit une espace normale échoue, et c'est le genre de détail qu'un test révèle.
✓ src/format.test.ts (3 tests) 4ms
Test Files 1 passed (1)
Tests 3 passed (3)
Tester un composant : render et screen
// src/components/BadgeEtat.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { BadgeEtat } from './BadgeEtat';
describe('BadgeEtat', () => {
it("affiche le libellé de l'état", () => {
render(<BadgeEtat etat="VA" />);
expect(screen.getByText('Validée')).toBeInTheDocument();
});
it("ajoute une classe propre à l'état", () => {
render(<BadgeEtat etat="CR" />);
expect(screen.getByText('Saisie en cours')).toHaveClass('badge-cr');
});
});
render affiche le composant dans le DOM simulé ; screen interroge tout ce qui est affiché. On cherche les éléments comme un utilisateur les voit : par leur texte, leur rôle, leur étiquette. Pas par une classe CSS ou un identifiant technique, qui peuvent changer sans que l'utilisateur voie de différence.
Trouver un élément : les requêtes
| Requête | Trouve | Exemple |
|---|---|---|
getByRole | Un élément par son rôle d'accessibilité et son nom | getByRole('button', { name: 'Ajouter le frais' }) |
getByLabelText | Un champ par le texte de son étiquette | getByLabelText('Libellé') |
getByText | Un élément par son texte | getByText('Validée') |
getByPlaceholderText, getByTestId | En dernier recours | getByTestId('total') avec data-testid="total" |
Testing Library recommande cet ordre de préférence : rôle d'abord. Un test qui trouve le bouton par getByRole('button', { name: 'Ajouter le frais' }) vérifie au passage que c'est un vrai bouton, avec un nom accessible. Si le test ne trouve pas l'élément par son rôle, un lecteur d'écran ne le trouvera pas non plus : les tests deviennent des tests d'accessibilité gratuits (chapitre 14).
Trois variantes pour chaque requête :
| Préfixe | Si absent | Si plusieurs | Usage |
|---|---|---|---|
getBy… | Lève une erreur | Lève une erreur | L'élément doit être là maintenant |
queryBy… | Renvoie null | Lève une erreur | Vérifier une absence : expect(screen.queryByRole('alert')).not.toBeInTheDocument() |
findBy… | Attend (1 s par défaut), puis lève une erreur | Lève une erreur | L'élément apparaîtra plus tard (après une réponse de l'API) : await screen.findByRole('alert') |
Et getAllBy…, queryAllBy…, findAllBy… renvoient des tableaux.
Simuler l'utilisateur avec user-event
import userEvent from '@testing-library/user-event';
const user = userEvent.setup();
await user.type(screen.getByLabelText('Libellé'), 'Taxi'); // tape lettre par lettre
await user.click(screen.getByRole('button', { name: 'Ajouter le frais' }));
await user.tab(); // passe au champ suivant
await user.selectOptions(screen.getByLabelText('État'), 'VA');
user-event reproduit toute la séquence d'événements d'une vraie interaction (focus, touche enfoncée, saisie, touche relâchée), là où l'ancien fireEvent n'envoie qu'un événement isolé. Toutes ses méthodes sont asynchrones : n'oublie pas les await.
Simuler l'API avec vi.mock
Le formulaire d'ajout appelle ajouterFrais, qui appelle l'API. Un test de composant ne doit pas dépendre d'un serveur : on remplace le module d'appels par des fonctions simulées avec vi.mock, puis on choisit ce qu'elles renvoient dans chaque test.
// src/components/FormulaireFrais.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FormulaireFrais } from './FormulaireFrais';
import { ajouterFrais } from '../api/fiches';
import { ApiError } from '../api/client';
// On remplace le module d'appels à l'API par des fonctions simulées.
vi.mock('../api/fiches');
function afficher() {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<FormulaireFrais ficheId={12} />
</QueryClientProvider>,
);
}
async function remplir(libelle: string, montant: string) {
const user = userEvent.setup();
await user.type(screen.getByLabelText('Date'), '2026-10-28');
await user.type(screen.getByLabelText('Libellé'), libelle);
await user.type(screen.getByLabelText('Montant (€)'), montant);
await user.click(screen.getByRole('button', { name: 'Ajouter le frais' }));
}
describe('FormulaireFrais', () => {
beforeEach(() => {
vi.mocked(ajouterFrais).mockReset();
});
it("envoie le frais saisi à l'API puis vide le formulaire", async () => {
vi.mocked(ajouterFrais).mockResolvedValue({ id: 60, date: '2026-10-28', libelle: 'Taxi', montant: 18.4 });
afficher();
await remplir('Taxi', '18.40');
expect(ajouterFrais).toHaveBeenCalledWith(12, { date: '2026-10-28', libelle: 'Taxi', montant: 18.4 });
expect(await screen.findByLabelText('Libellé')).toHaveValue('');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it("affiche l'erreur de l'API et garde la saisie", async () => {
vi.mocked(ajouterFrais).mockRejectedValue(
new ApiError(409, "La fiche est à l'état CL : seuls les frais d'une fiche en cours se modifient."),
);
afficher();
await remplir('Taxi', '18.40');
expect(await screen.findByRole('alert')).toHaveTextContent("seuls les frais d'une fiche en cours se modifient");
expect(screen.getByLabelText('Libellé')).toHaveValue('Taxi');
});
});
Ce qu'il faut retenir :
vi.mock('../api/fiches')remplace chaque fonction exportée du module par une fonction simulée qui ne fait rien. L'appel est remonté en haut du fichier par Vitest : il s'applique avant lesimport.vi.mocked(ajouterFrais)donne le bon type à la fonction simulée, pour que TypeScript acceptemockResolvedValue(la promesse réussit avec cette valeur) etmockRejectedValue(la promesse échoue avec cette erreur).toHaveBeenCalledWithvérifie que le composant a appelé l'API avec les bonnes données : ici, le montant a bien été converti en nombre.mockReset()dansbeforeEachremet la fonction à zéro entre deux tests, pour qu'ils restent indépendants.- Le
QueryClientProviderest nécessaire parce que le formulaire appelleuseQueryClient(). Chaque test crée un client neuf : pas de cache partagé entre les tests.
Le test vérifie un comportement (ce que l'utilisateur voit et ce qui part vers l'API), pas l'implémentation (le nom des états internes, l'utilisation de useActionState). On pourrait réécrire le formulaire avec des champs contrôlés : le test passerait toujours.
Tester une route protégée
Pour tester un composant qui utilise le routeur, on crée un routeur en mémoire avec createMemoryRouter, qui ne touche pas à la barre d'adresse. Et pour contrôler l'utilisateur connecté, on fournit directement une valeur au contexte d'authentification, sans passer par l'API :
// src/auth/RouteProtegee.test.tsx
import { render, screen } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { describe, expect, it } from 'vitest';
import { AuthContext, type AuthValeur } from './auth-context';
import { RouteProtegee } from './RouteProtegee';
import type { Utilisateur } from '../types';
const lucas: Utilisateur = { id: 7, login: 'lmartin', nom: 'Martin', prenom: 'Lucas', role: 'visiteur' };
function afficher(utilisateur: Utilisateur | null, role?: 'visiteur' | 'comptable') {
const auth: AuthValeur = {
utilisateur,
seConnecter: async () => {},
seDeconnecter: () => {},
};
const router = createMemoryRouter(
[
{ path: '/connexion', element: <h1>Connexion</h1> },
{ element: <RouteProtegee role={role} />, children: [{ path: '/', element: <h1>Mes fiches</h1> }] },
],
{ initialEntries: ['/'] },
);
render(
<AuthContext value={auth}>
<RouterProvider router={router} />
</AuthContext>,
);
}
describe('RouteProtegee', () => {
it('redirige vers la connexion sans utilisateur', () => {
afficher(null);
expect(screen.getByRole('heading', { name: 'Connexion' })).toBeInTheDocument();
});
it('affiche la page à un utilisateur connecté', () => {
afficher(lucas);
expect(screen.getByRole('heading', { name: 'Mes fiches' })).toBeInTheDocument();
});
it("refuse un utilisateur qui n'a pas le rôle demandé", () => {
afficher(lucas, 'comptable');
expect(screen.getByRole('alert')).toHaveTextContent('réservée au rôle « comptable »');
});
});
C'est l'intérêt d'avoir séparé le contexte (auth-context.ts) du fournisseur (AuthProvider.tsx) : le test injecte la valeur qu'il veut. Les pages sont remplacées par de simples titres : on teste la logique de redirection, rien d'autre.
Lancer les tests
$ npx vitest run
✓ src/format.test.ts (3 tests) 4ms
✓ src/components/BadgeEtat.test.tsx (2 tests) 21ms
✓ src/auth/RouteProtegee.test.tsx (3 tests) 48ms
✓ src/components/FormulaireFrais.test.tsx (2 tests) 412ms
Test Files 4 passed (4)
Tests 10 passed (10)
Pour savoir quelles lignes ne sont jamais exécutées par les tests, installe @vitest/coverage-v8 et lance npx vitest run --coverage : un rapport de couverture s'affiche par fichier. Une couverture de 100 % n'est pas un objectif : couvre d'abord la logique métier (calculs, règles, gestion des erreurs).
Que tester, et quoi laisser
| À tester en priorité | Inutile de tester |
|---|---|
| Les fonctions de calcul et de mise en forme | Que React affiche bien un <p> (c'est le travail de React) |
| Les règles métier visibles (un frais ne s'ajoute pas à une fiche clôturée) | Le style CSS exact |
| L'affichage des erreurs de l'API | Le fonctionnement interne de TanStack Query ou de React Router |
| Les redirections d'authentification | Chaque prop de chaque petit composant d'affichage |
| Chaque bogue corrigé : un test qui le reproduit, pour qu'il ne revienne pas | Le code généré ou les fichiers de configuration |
Exercices
Exercice 1 sur 3 Choisir la requête Réussi À revoir
Écris la requête Testing Library la plus adaptée pour chaque élément.
- Le bouton « Se connecter ».
- Le champ dont l'étiquette est « Mot de passe ».
- Le message d'erreur (un
<p role="alert">) qui apparaît après la réponse de l'API. - Vérifier qu'aucun message d'erreur n'est affiché.
- Le titre de niveau 1 « Mes fiches de frais ».
Voir la réponseMasquer la réponse de l'exercice 1
screen.getByRole('button', { name: 'Se connecter' })screen.getByLabelText('Mot de passe')await screen.findByRole('alert'): il apparaît plus tard.expect(screen.queryByRole('alert')).not.toBeInTheDocument():queryByrenvoienullau lieu de lever une erreur.screen.getByRole('heading', { level: 1, name: 'Mes fiches de frais' })
Tu avais trouvé ?
Exercice 2 sur 3 Tester le filtre Réussi À revoir
Écris un test pour FiltreEtat : on l'affiche avec valeur="" et une fonction simulée onChange = vi.fn(), on choisit « Validées » dans la liste, et on vérifie que onChange a été appelée avec 'VA'.
Voir la réponseMasquer la réponse de l'exercice 2
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { FiltreEtat } from './FiltreEtat';
it("prévient le parent quand on choisit un état", async () => {
const onChange = vi.fn();
render(<FiltreEtat valeur="" onChange={onChange} />);
await userEvent.setup().selectOptions(screen.getByLabelText('État'), 'VA');
expect(onChange).toHaveBeenCalledWith('VA');
});
selectOptions accepte la valeur ('VA') ou le texte de l'option ('Validées'). Le composant est contrôlé par son parent : le test vérifie qu'il prévient le parent, pas que la liste change d'elle-même.
Tu avais trouvé ?
Exercice 3 sur 3 Le test de non-régression Réussi À revoir
Un utilisateur signale : « quand je saisis un montant de 0, le frais est envoyé quand même ». Tu corriges FormulaireFrais. Écris le test qui prouve la correction et qui échouerait sur l'ancienne version. (Le champ a l'attribut min="0.01" : pour tester la vérification JavaScript, retire la validation du navigateur en ajoutant noValidate au formulaire, ou teste avec un montant que le navigateur accepte mais que la règle refuse.)
Voir la réponseMasquer la réponse de l'exercice 3
Avec un formulaire en noValidate :
it("refuse un montant nul sans appeler l'API", async () => {
afficher();
await remplir('Taxi', '0');
expect(await screen.findByRole('alert')).toHaveTextContent('Le montant doit être supérieur à 0.');
expect(ajouterFrais).not.toHaveBeenCalled();
});
Le test vérifie les deux faces du bogue : le message apparaît, et aucune requête ne part. Il reste dans la suite de tests : si une future modification réintroduit le bogue, il échouera. Mentionne-le dans ton dossier E6 comme test de non-régression.
Tu avais trouvé ?