frontend / react
Tester avec React Testing Library
Explication
Ce que vous allez apprendre
- Comprendre la philosophie de React Testing Library : tester comme un utilisateur, jamais l'implémentation
- Choisir la bonne requête (
getBy*,queryBy*,findBy*) selon le contexte - Simuler une interaction réaliste avec
userEventplutôt qu'avecfireEvent - Tester un formulaire complet, de la saisie à la soumission et à l'affichage d'une erreur
- Vérifier une absence d'élément sans faire échouer le test par erreur
Dans quel contexte ?
Après un refactoring de src/components/LoginForm.jsx qui renomme une variable interne email en emailValue, la suite de tests existante casse entièrement alors que le formulaire fonctionne toujours parfaitement pour l'utilisateur. Le test précédent vérifiait l'état interne du composant plutôt que ce qui est réellement affiché à l'écran. Réécrire ce test avec RTL, en cherchant le champ par son label et en simulant une vraie frappe utilisateur, le rend robuste à ce genre de refactoring interne.
Étape 1 : à quoi sert un test automatisé
Un test automatisé sert à vérifier qu'un composant continue de se comporter correctement après une modification, sans repasser manuellement toute l'application au crible.
Étape 2 : la philosophie de React Testing Library
React Testing Library (RTL) repose sur un principe assez radical : tester une interface comme le ferait un vrai utilisateur, jamais en s'appuyant sur des détails d'implémentation internes.
Étape 3 : ce que ça signifie concrètement
Un test RTL cherche des éléments par leur rôle accessible ou leur texte visible, et simule des interactions réalistes, plutôt que d'appeler directement une fonction interne.
Étape 4 : pourquoi cette approche est préférable
Un test qui vérifie des détails d'implémentation casse dès qu'on refactore le code interne, même si le comportement visible reste identique — ce qui décourage de refactoriser.
Étape 5 : un bénéfice supplémentaire inattendu
En testant ce que l'utilisateur voit et fait, les tests RTL vérifient indirectement l'accessibilité : un test qui ne trouve pas un bouton par son rôle révèle souvent un vrai problème pour les lecteurs d'écran.
Étape 6 : trois familles de requêtes à connaître
getBy* lève une erreur si l'élément est absent. queryBy* retourne null sans erreur, utile pour vérifier une ABSENCE. findBy* retourne une promesse qui attend l'apparition d'un élément asynchrone.
Piège fréquent
Utiliser getByText("Erreur") pour vérifier qu'un message d'erreur n'apparaît PAS lève une exception si l'élément est absent, faisant échouer le test pour la mauvaise raison. Pour une vérification d'absence, il faut queryByText("Erreur") combiné à .not.toBeInTheDocument().
| Requête | Comportement si absent | Cas d'usage |
|---|---|---|
getByRole / getByText | Lève une exception | Élément qui doit être présent immédiatement |
queryByRole / queryByText | Retourne null | Vérifier une absence |
findByRole / findByText | Attend, puis lève une exception au timeout | Élément qui apparaît après une action asynchrone |
Et ensuite ?
La priorité recommandée va des requêtes les plus accessibles aux moins recommandées. Une fois les tests avec RTL maîtrisés, la prochaine étape aborde le state management externe avec Zustand et Redux.
Commandes & code
Tester avec React Testing Library
Philosophie centrale : tester ce que l'UTILISATEUR voit et fait, jamais les détails d'implémentation internes.
// Composant à tester
function FormulaireConnexion({ onConnexion }) {
const [email, setEmail] = useState("");
const [erreur, setErreur] = useState("");
function gererSubmit(e) {
e.preventDefault();
if (!email.includes("@")) {
setErreur("Email invalide");
return;
}
onConnexion(email);
}
return (
<form onSubmit={gererSubmit}>
<label htmlFor="email">Email</label>
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} />
{erreur && <p role="alert">{erreur}</p>}
<button type="submit">Se connecter</button>
</form>
);
}import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
describe("FormulaireConnexion", () => {
it("appelle onConnexion avec un email valide", async () => {
const user = userEvent.setup();
const onConnexion = vi.fn();
render(<FormulaireConnexion onConnexion={onConnexion} />);
// getByRole/getByLabelText : requêtes accessibles, PAS de sélection par classe CSS/id
const champEmail = screen.getByLabelText("Email");
await user.type(champEmail, "marie@exemple.com");
await user.click(screen.getByRole("button", { name: "Se connecter" }));
expect(onConnexion).toHaveBeenCalledWith("marie@exemple.com");
});
it("affiche une erreur pour un email invalide", async () => {
const user = userEvent.setup();
render(<FormulaireConnexion onConnexion={vi.fn()} />);
await user.type(screen.getByLabelText("Email"), "pas-un-email");
await user.click(screen.getByRole("button", { name: "Se connecter" }));
expect(screen.getByRole("alert")).toHaveTextContent("Email invalide");
});
});// Tester un composant asynchrone : findBy* attend automatiquement l'apparition
import { render, screen } from "@testing-library/react";
it("affiche la liste après le chargement", async () => {
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve([{ id: 1, titre: "HTML" }]),
});
render(<ListeCours />);
expect(screen.getByText("Chargement...")).toBeInTheDocument();
// findByText retourne une Promise -- attend jusqu'à un timeout par défaut (1s)
const item = await screen.findByText("HTML");
expect(item).toBeInTheDocument();
expect(screen.queryByText("Chargement...")).not.toBeInTheDocument();
// queryBy* (contrairement à getBy*) retourne null au lieu de lever -- utile pour vérifier une ABSENCE
});// Mock d'un hook custom pour isoler le composant testé
vi.mock("./useAuth", () => ({
useAuth: () => ({ utilisateur: { nom: "Marie" }, deconnexion: vi.fn() }),
}));
// Priorité recommandée des requêtes RTL, de la plus à la moins accessible :
// getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId (dernier recours)Résumé
- RTL privilégie les requêtes accessibles (
getByRole,getByLabelText) : le test échoue aussi si un lecteur d'écran galère. getBy*lève une erreur si absent,queryBy*retournenull,findBy*attend une apparition asynchrone.userEventsimule des interactions réalistes (focus, frappe, clic) plus fidèles quefireEventbas niveau.getByTestIdreste un dernier recours, seulement quand aucune requête accessible n'est raisonnablement possible.
Exercices pratiques
Mission : réparer les tests cassés par un renommage inoffensif
Objectif : Réécrire un test qui vérifiait un détail d'implémentation interne pour qu'il vérifie uniquement ce que voit l'utilisateur, puis corriger une vérification d'absence bâclée.
Contexte
Un refactoring de LoginForm.jsx renomme la variable interne email en emailValue. La suite de tests existante, qui appelait wrapper.state('email') pour vérifier la valeur saisie, casse entièrement — alors que le formulaire fonctionne toujours parfaitement pour l'utilisateur. Il faut réécrire ce test avec RTL. Un second test, censé vérifier qu'aucun message d'erreur ne s'affiche après une saisie valide, utilise actuellement getByText("Email invalide") suivi de .not.toBeInTheDocument() : il faut identifier pourquoi ce test plante pour la mauvaise raison.