Tests React : guide pratique avec Testing Library

Pourquoi tester vos composants React
Confession : j'ai longtemps été le dev qui disait "je teste manuellement, c'est plus rapide". Jusqu'au jour où j'ai cassé le formulaire de paiement d'un client en production. Un refactoring innocent, un if inversé, et 2h de transactions perdues avant qu'on s'en rende compte.
Depuis, je teste. Pas tout (on verra pourquoi), mais les parties critiques. React Testing Library rend ça presque agréable.
La philosophie Testing Library
"Plus vos tests ressemblent à la façon dont votre logiciel est utilisé, plus ils vous donnent confiance."
Testez le comportement, pas l'implémentation :
// ❌ Mauvais - teste l'implémentation
expect(component.state.isOpen).toBe(true);
// ✅ Bon - teste le comportement
expect(screen.getByRole("dialog")).toBeInTheDocument();
Configuration avec Vitest
J'ai migré de Jest à Vitest sur un projet récent. Les tests qui prenaient 45 secondes s'exécutent maintenant en 8 secondes. Pour le TDD, cette différence est énorme - le feedback loop change tout.
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.ts",
},
});
// src/test/setup.ts
import "@testing-library/jest-dom";
Queries : trouver les éléments
Utilisez les queries dans cet ordre de priorité :
1. Accessibles à tous
// Par rôle (préféré)
screen.getByRole("button", { name: /soumettre/i });
screen.getByRole("heading", { level: 1 });
// Par label
screen.getByLabelText(/email/i);
// Par placeholder
screen.getByPlaceholderText(/rechercher/i);
// Par texte
screen.getByText(/bienvenue/i);
2. Sémantiques
// Par alt text (images)
screen.getByAltText(/logo/i);
// Par title
screen.getByTitle(/fermer/i);
3. Test IDs (dernier recours)
// Seulement si aucune autre option
screen.getByTestId("custom-element");
Test d'un composant simple
// Button.tsx
interface ButtonProps {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}
export function Button({ children, onClick, disabled }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('appelle onClick quand cliqué', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Cliquez</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('ne déclenche pas onClick quand disabled', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Cliquez</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
Test avec async/await
Pour les opérations asynchrones, utilisez findBy :
// UserProfile.tsx
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
if (!user) return <div>Chargement...</div>;
return <div>{user.name}</div>;
}
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// Mock du fetch
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: 'Jean Dupont' }),
}));
describe('UserProfile', () => {
it('affiche le nom après chargement', async () => {
render(<UserProfile userId="123" />);
// Vérifie l'état de chargement
expect(screen.getByText(/chargement/i)).toBeInTheDocument();
// Attend le résultat
expect(await screen.findByText('Jean Dupont')).toBeInTheDocument();
});
});
Test de formulaires
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('soumet le formulaire avec les bonnes valeurs', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Remplir le formulaire
await userEvent.type(
screen.getByLabelText(/email/i),
'test@example.com'
);
await userEvent.type(
screen.getByLabelText(/mot de passe/i),
'password123'
);
// Soumettre
await userEvent.click(screen.getByRole('button', { name: /connexion/i }));
// Vérifier
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('affiche une erreur si email invalide', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await userEvent.type(screen.getByLabelText(/email/i), 'invalid');
await userEvent.click(screen.getByRole('button', { name: /connexion/i }));
expect(screen.getByText(/email invalide/i)).toBeInTheDocument();
});
});
Mocking des hooks personnalisés
// useAuth.ts
export function useAuth() {
// Logique d'authentification
return { user, login, logout };
}
// Component.test.tsx
vi.mock("./useAuth", () => ({
useAuth: vi.fn(),
}));
import { useAuth } from "./useAuth";
beforeEach(() => {
vi.mocked(useAuth).mockReturnValue({
user: { id: "1", name: "Test User" },
login: vi.fn(),
logout: vi.fn(),
});
});
Bonnes pratiques
| À faire | À éviter |
|---|---|
| Tester le comportement utilisateur | Tester les détails d'implémentation |
Utiliser userEvent | Utiliser fireEvent directement |
| Queries par rôle/label | Queries par classe CSS |
| Un concept par test | Tests qui testent tout |
| Noms de tests descriptifs | Noms génériques |
Structure de test recommandée
describe("ComponentName", () => {
// Setup commun si nécessaire
beforeEach(() => {
// Reset des mocks
});
describe("rendu initial", () => {
it("affiche le titre", () => {});
it("affiche le bouton désactivé", () => {});
});
describe("interactions", () => {
it("active le bouton après saisie", () => {});
it("soumet le formulaire", () => {});
});
describe("gestion des erreurs", () => {
it("affiche une erreur réseau", () => {});
});
});
Conclusion
React Testing Library m'a réconcilié avec les tests. Avant, mes tests cassaient à chaque refactoring parce qu'ils testaient l'implémentation. Maintenant, ils testent le comportement - je peux refactorer sereinement tant que l'UX reste identique.
Le plus satisfaisant ? Quand les tests passent au vert après un gros refactoring et que je sais que je n'ai rien cassé. Ce feeling n'a pas de prix.
Mon conseil : ne visez pas 100% de couverture. J'ai travaillé sur un projet avec 95% de coverage et des bugs en prod quand même. Testez les chemins critiques (auth, paiement, données utilisateur) à fond. Le reste, faites au feeling.
Amor GABTNI
Développeur Full Stack & Mobile