Que fait React Testing ?
Modèles de test React complets pour des tests de composants axés sur le comportement, des tests de hooks personnalisés, des assertions d'accessibilité et le mock au niveau réseau.
Quand activer
- Écrire des tests pour des composants React, des hooks personnalisés ou des pages
- Ajouter une couverture de test à des composants hérités non testés
- Migrer d'Enzyme ou de modèles d'époque de classes vers React Testing Library
- Configurer Vitest ou Jest pour un nouveau projet React
- Mocker des requêtes HTTP dans les tests
- Vérifier les violations d'accessibilité
- Décider quels tests appartiennent à RTL, Playwright Component Testing ou aux tests E2E complets
Principe Fondamental
Testez ce que l'utilisateur voit et fait, pas les détails d'implémentation.
Un test doit :
- Rendre le composant avec les mêmes fournisseurs qu'en production
- Interagir avec lui via des requêtes accessibles (rôle, label) et
userEvent - Vérifier la sortie visible et les effets de bord observables (callback exécuté, requête envoyée)
Un test NE doit PAS :
- Inspecter l'état du composant, les props passées aux enfants ou les hooks appelés
- Mocker React lui-même ou les hooks du framework
- Vérifier le nombre de rendus ou la structure du DOM au-delà de ce qui affecte les utilisateurs
Choix de la Bibliothèque
| Exécuteur | Quand | Remarque |
|---|---|---|
| Vitest | Vite, Remix, configurations modernes | Plus rapide, ESM natif, API compatible Jest |
| Jest | Next.js, CRA, dépôts établis | Par défaut pour de nombreux projets React |
| Playwright Component Testing | Moteur de navigateur réel nécessaire | Utilisez lorsque JSDOM manque de la fonctionnalité requise |
| Cypress Component Testing | Navigateur réel, Cypress déjà utilisé | Alternative à Playwright CT |
Choisissez-en un. N'exécutez pas RTL + Vitest ET Playwright CT dans le même dépôt à moins d'avoir une séparation de voies claire.
Priorité des Requêtes
React Testing Library expose les requêtes en trois niveaux – utilisez du haut vers le bas :
- Accessible à tous :
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - Sémantique :
getByAltText,getByTitle - IDs de test (échappatoire) :
getByTestId
// Meilleur
screen.getByRole("button", { name: /save/i });
// OK pour les champs de saisie
screen.getByLabelText("Email");
// Dernier recours
screen.getByTestId("save-btn");
Variantes :
getBy*— lève une exception si aucune correspondancequeryBy*— retournenull(utilisez pour "vérifier l'absence")findBy*— asynchrone, retourne une Promesse (utilisez pour les éléments qui apparaissent après un travail asynchrone)
Interaction Utilisateur avec userEvent
import userEvent from "@testing-library/user-event";
test("soumet le formulaire", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]" });
});
- Utilisez toujours
awaitsur les appels userEvent - Appelez
userEvent.setup()une fois par test, réutilisez l'objetuserretourné userEventsimule une séquence de navigateur réelle ;fireEventdéclenche un seul événement synthétique — préférezuserEvent
Motifs Asynchrones
// Élément qui apparaît après un travail asynchrone
expect(await screen.findByText("Loaded")).toBeInTheDocument();
// Vérification d'effet de bord
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// Élément qui devrait disparaître
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
N'utilisez jamais setTimeout + assertion — instable. Utilisez les matchers ci-dessus.
Mock Réseau avec MSW
Mock Service Worker mocke au niveau réseau. Le composant, les hooks et la bibliothèque fetch se comportent exactement comme en production.
Configuration
// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Alice" }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Configurez onUnhandledRequest: "error" pour que toute requête non mockée fasse échouer le test de manière bruyante — les passages silencieux sont pires que les échecs.
Surcharge par test
test("affiche l'erreur sur 500", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/une erreur s'est produite/i)).toBeInTheDocument();
});
Encapsulation des Fournisseurs
Encapsulez les fournisseurs une fois dans un test-utils.tsx :
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
);
}
export * from "@testing-library/react";
Importez ensuite import { renderWithProviders, screen } from "test-utils" dans chaque fichier de test.
Test de Hooks Personnalisés
import { renderHook, act } from "@testing-library/react";
test("useCounter incrémente et décrémente", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
test("useCounter accepte une valeur initiale", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser récupère les données utilisateur", async () => {
// Instancier QueryClient UNE FOIS par test en dehors du wrapper pour qu'il survive aux ré-rendus.
// Le créer à l'intérieur de la fermeture du wrapper réinitialise l'état du cache à chaque rendu, produisant des tests instables.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: "1", name: "Alice" });
});
- Encapsulez les appels modifiant l'état dans
act - Testez via l'API publique du hook uniquement
- Pour les hooks qui utilisent le contexte, passez un
wrapper
Assertions d'Accessibilité
import { axe, toHaveNoViolations } from "jest-axe"; // ou vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard n'a pas de violations d'accessibilité", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});
Exécutez axe dans les tests de composants pour chaque composant interactif. Il détecte :
- Étiquettes manquantes sur les champs de formulaire
- Utilisation invalide d'ARIA
- Mauvais contraste de couleur (limité — JSDOM n'a pas de moteur CSS réel, donc cela fonctionne uniquement pour les styles en ligne ; le contraste visuel relève de Playwright)
- Texte alternatif manquant sur les images
- Violations d'ordre des titres
Lien croisé : skills/accessibility/SKILL.md pour le guide de test d'accessibilité plus large.
Quand NE PAS Utiliser les Tests de Snapshot
Les snapshots de sortie rendue :
- Cassent à chaque changement de style
- Sont validés sans réelle vérification pendant la revue
- Testent le détail d'implémentation (structure du DOM), pas le comportement
Utilisations acceptables des snapshots :
- Fonctions de sérialisation de données pures (
formatInvoice(facture)-> chaîne stable) - Fichiers de configuration générés (par exemple, sortie de configuration webpack)
Pour la régression visuelle sur les composants, utilisez les captures d'écran Playwright/Cypress ou Percy/Chromatic — des diffs visuels réels, pas des chaînes DOM.
Quand Utiliser Playwright / Cypress
JSDOM (utilisé par Vitest/Jest) ne peut pas :
- Rendre la mise en page réelle (flexbox, grille, requêtes de viewport)
- Exécuter des animations natives du navigateur, des transitions CSS
- Tester le comportement de défilement, le glisser-déposer, le collage depuis le presse-papier
- Gérer les iframes, popups, téléchargements, flux cross-origin
- Exécuter un réseau réel dans un environnement contrôlé avec le support complet des DevTools
Pour tout cela, utilisez Playwright Component Testing (test de composant dans un navigateur réel) ou un test E2E complet. Voir e2e-testing skill.
Frontière décisionnelle :
- Un hook, un composant de présentation, un formulaire avec logique -> RTL
- Un composant dont la mise en page est importante ou qui utilise des API navigateur non présentes dans JSDOM -> Playwright CT
- Un flux utilisateur complet à travers plusieurs pages -> Playwright/Cypress E2E
Objectifs de Couverture
| Couche | Objectif |
|---|---|
| Utilitaires purs | >=90% |
| Hooks personnalisés | >=85% |
| Composants de présentation | >=80% — comportement, pas lignes |
| Composants conteneurs | >=70% — chemins dorés + états d'erreur |
| Pages | E2E couvert séparément ; test de fumée minimum |
Configurez via vitest.config.ts / jest.config.js :
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}
Anti-Patrons
container.querySelector("...")— contourne les requêtes d'accessibilité, laisse les tests passer alors que les vrais utilisateurs échoueraient- Vérifier le nombre de rendus — détail d'implémentation
jest.mock("react", ...)— ne mockez jamais React. Refactorisez le composant à la place- Mocker les composants enfants par défaut — teste l'intégration, pas l'isolation. Mockez uniquement lorsque l'enfant a des effets de bord lourds
- Ignorer les avertissements
act()— ils signalent de vrais bugs (mise à jour d'état après démontage, encapsulation asynchrone manquante) - Partager un état mutable entre les tests — crée des tests instables lorsque l'ordre des tests change
- Tests qui passent avec
it.skip()supprimé — votre test ne vérifie pas réellement ce que vous pensez
Workflow TDD
ROUGE -> Écrire un test échouant pour la prochaine exigence
VERT -> Écrire le code minimal du composant pour passer
REFACTORISATION -> Améliorer le composant, les tests restent verts
RÉPÉTER -> Prochaine exigence
Pour les nouveaux composants :
- Définir le type de prop et la signature du composant
- Écrire le premier test pour le cas le plus simple
- Vérifier qu'il échoue pour la bonne raison
- Implémenter juste assez pour passer
- Ajouter le cas de test suivant
- Refactoriser lorsque le troisième test similaire révèle un motif
Commandes de Test
# Vitest
vitest # surveillance
vitest run # exécution unique
vitest run --coverage # avec couverture
vitest run path/to/file.test.tsx # fichier unique
# Jest
jest --watch
jest --coverage
jest path/to/file.test.tsx
# Mode CI
CI=true vitest run --coverage
Liens connexes
- Règles : rules/react/testing.md
- Skills : react-patterns, accessibility, e2e-testing, tdd-workflow
- Agents :
react-reviewer(vérifie la qualité des tests pendant la revue de code),tdd-guide(applique le processus TDD) - Commandes :
/react-test,/react-review
Exemples
Soumission de formulaire avec MSW et userEvent
test("soumet le formulaire utilisateur et affiche le succès", async () => {
server.use(
http.post("/api/users", () =>
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }),
),
);
const user = userEvent.setup();
renderWithProviders(<UserForm />);
await user.type(screen.getByLabelText("Name"), "Alice");
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText(/sauvegardé avec succès/i)).toBeInTheDocument();
});
Test d'une barrière d'erreur (Error Boundary)
function Broken() {
throw new Error("boum");
}
test("la barrière d'erreur affiche le contenu de repli", () => {
// Supprimer le bruit console.error de React pour l'erreur attendue, puis restaurer pour
// que l'espion ne fuite pas entre les tests et ne cache pas de vraies erreurs ailleurs.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Une erreur s'est produite</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Une erreur s'est produite")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
Test d'une barrière Suspense
test("affiche le chargement puis le contenu", async () => {
renderWithProviders(
<Suspense fallback={<div>Chargement...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Chargement...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});