Test React
Modèles complets de test React pour des tests de composants axés sur le comportement, des tests de hooks personnalisés, des assertions d'accessibilité et du mocking au niveau réseau.
Quand activer
- Écrire des tests pour des composants React, hooks personnalisés ou pages
- Ajouter une couverture de test à des composants hérités non testés
- Migrer depuis Enzyme ou des motifs de l'ère des classes vers React Testing Library
- Configurer Vitest ou Jest pour un nouveau projet React
- Moquer des requêtes HTTP dans les tests
- Vérifier les violations d'accessibilité
- Décider quels tests appartiennent à RTL vs Playwright Component Testing vs E2E complet
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 via des requêtes accessibles (rôle, label) et
userEvent - Vérifier la sortie visible et les effets secondaires observables (callback appelé, requête envoyée)
Un test ne doit PAS :
- Inspecter l'état du composant, les props passées aux enfants ou quels hooks ont été appelés
- Moquer React lui-même ou des hooks du framework
- Vérifier le nombre de rendus ou la structure DOM au-delà de ce qui affecte les utilisateurs
Choix de bibliothèque
| Lanceur | Quand | Note |
|---|---|---|
| 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 | Utiliser quand JSDOM ne dispose pas de la fonctionnalité requise |
| Cypress Component Testing | Navigateur réel, Cypress déjà utilisé | Alternative à Playwright CT |
Choisissez-en un. N'utilisez pas RTL + Vitest ET Playwright CT dans le même dépôt sans une séparation claire des responsabilités.
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 entrées
screen.getByLabelText("Email");
// Dernier recours
screen.getByTestId("save-btn");
Variantes :
getBy*— lève une exception si aucun élément ne correspondqueryBy*— retournenull(utiliser pour "vérifier l'absence")findBy*— asynchrone, retourne une Promise (utiliser 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]" });
});
awaittoujours les appelsuserEvent- Appelez
userEvent.setup()une fois par test, réutilisez l'objetuserretourné userEventsimule une séquence réelle de navigateur ;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();
// Assertion d'effet secondaire
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.
Mocking réseau avec MSW
Mock Service Worker mime 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 échoue bruyamment — les passages silencieux sont pires que les échecs.
Surcharge par test
test("affiche une erreur en cas de 500", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
Encapsulation des fournisseurs
Encapsulez les fournisseurs une fois dans un fichier 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";
Ensuite importez 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 () => {
// Instanciez QueryClient UNE FOIS par test en dehors du wrapper pour qu'il survive aux re-rendus.
// Le créer dans la fermeture du wrapper réinitialise le 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 uniquement via l'API publique du hook
- 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. Détecte :
- Étiquettes manquantes sur les entrées de formulaire
- Utilisation invalide d'ARIA
- Mauvais contraste des couleurs (limité — JSDOM n'a pas de moteur CSS réel, donc cela fonctionne uniquement pour les styles en ligne ; le contraste visuel appartient à Playwright)
- Texte alternatif manquant sur les images
- Violations de l'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 du rendu :
- Cassent à chaque modification de style
- Sont validés mécaniquement pendant la revue
- Testent un détail d'implémentation (structure DOM), pas le comportement
Utilisations acceptables des snapshots :
- Fonctions de sérialisation de données pures (
formatInvoice(invoice)-> 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 différences visuelles réelles, pas des chaînes DOM.
Quand utiliser Playwright / Cypress
JSDOM (utilisé par Vitest/Jest) ne peut pas :
- Rendre une 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 support complet des DevTools
Pour tout cela, utilisez Playwright Component Testing (test de composant dans un navigateur réel) ou E2E complet. Voir e2e-testing skill.
Limite de décision :
- Un hook, un composant de présentation, un formulaire avec logique -> RTL
- Un composant dont la mise en page importe ou qui utilise des API navigateur absentes de JSDOM -> Playwright CT
- Un flux utilisateur complet sur 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 principaux + états d'erreur |
| Pages | E2E couvert séparément ; test de base 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 accessibles, 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. Refactorez le composant à la place- Moquer les composants enfants par défaut — testez l'intégration, pas l'isolation. Mokez uniquement lorsque l'enfant a des effets secondaires lourds
- Ignorer les avertissements
act()— ils signalent de vrais bugs (mise à jour d'état après démontage, absence d'encapsulation asynchrone) - Partager un état mutable entre les tests — instable 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 qui échoue pour la prochaine exigence
VERT -> Écrire le code minimal du composant pour réussir
REFACTOR -> Améliorer le composant, les tests restent verts
RÉPÉTER -> Prochaine exigence
Pour les nouveaux composants :
- Définir le type de props 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 réussir
- Ajouter le cas de test suivant
- Refactorer lorsque le troisième test similaire révèle un motif
Commandes de test
# Vitest
vitest # mode watch
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
- Compétences : react-patterns, accessibility, e2e-testing, tdd-workflow
- Agents :
react-reviewer(revue 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(/saved successfully/i)).toBeInTheDocument();
});
Test d'une limite d'erreur
function Broken() {
throw new Error("boom");
}
test("la limite 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 les vraies erreurs ailleurs.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
Test d'une limite Suspense
test("affiche le chargement puis le contenu", async () => {
renderWithProviders(
<Suspense fallback={<div>Loading...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});