Aller au contenu principal
Retour au blog

Tests React : guide pratique avec Testing Library

5 min
Partager :
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 utilisateurTester les détails d'implémentation
Utiliser userEventUtiliser fireEvent directement
Queries par rôle/labelQueries par classe CSS
Un concept par testTests qui testent tout
Noms de tests descriptifsNoms 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.

A

Amor GABTNI

Développeur Full Stack & Mobile

Articles similaires