Retour au cours

frontend / react

Tester avec React Testing Library

Leçon 191 exercice

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 userEvent plutôt qu'avec fireEvent
  • 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êteComportement si absentCas d'usage
getByRole / getByTextLève une exceptionÉlément qui doit être présent immédiatement
queryByRole / queryByTextRetourne nullVérifier une absence
findByRole / findByTextAttend, 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.

jsx
// 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>
    );
}
jsx
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");
    });
});
jsx
// 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
});
jsx
// 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* retourne null, findBy* attend une apparition asynchrone.
  • userEvent simule des interactions réalistes (focus, frappe, clic) plus fidèles que fireEvent bas niveau.
  • getByTestId reste un dernier recours, seulement quand aucune requête accessible n'est raisonnablement possible.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →