Front-end angularforall.com

- React Testing Library : tester composants

React Testing-Library Userevent Msw Mock-Service-Worker Renderhook Jest-Axe Jest-Dom Vitest Tests-Unitaires Accessibilite Wcag
React Testing Library : tester composants

React Testing Library : queries getBy/findBy/queryBy, userEvent, MSW mock API, renderHook hooks, jest-axe accessibilite et matchers jest-dom.

Pourquoi tester ses composants React ?

Beaucoup de développeurs junior esquivent les tests — soit par manque de temps, soit parce qu'ils ne savent pas par où commencer. Résultat : des bugs en production, des régressions silencieuses, et une peur croissante de modifier le code existant. Pourtant, bien tester une application React n'est pas aussi complexe qu'on le croit, surtout avec React Testing Library (RTL).

Les 3 types de tests à connaître

En frontend React, on distingue trois niveaux de tests qui se complètent :

Type Ce qu'il teste Outil principal Vitesse Coût maintenance
Unitaire Fonction ou hook isolé Jest / Vitest ⚡ Très rapide Faible
Intégration Composant avec contexte réel React Testing Library 🚀 Rapide Moyen
E2E Parcours utilisateur complet Playwright / Cypress 🐢 Lent Élevé

React Testing Library se positionne comme l'outil d'intégration par excellence. Sa philosophie est unique : plutôt que de tester les détails d'implémentation (état interne, méthodes privées), RTL teste ce que voit et fait l'utilisateur. C'est cette approche qui la rend plus robuste et moins sujette aux faux positifs.

Philosophie RTL : "Plus vos tests ressemblent à la façon dont votre logiciel est utilisé, plus ils vous donneront confiance." — Kent C. Dodds, créateur de RTL.

Ce que RTL NE fait pas

RTL ne remplace pas Jest (qui gère l'exécution et les assertions). La stack complète est : Jest (runner) + @testing-library/react (render + queries) + @testing-library/user-event (interactions) + @testing-library/jest-dom (matchers HTML).

Installation et configuration

Avec Create React App (déjà configuré)

Si vous utilisez Create React App, RTL est inclus par défaut. Vous pouvez directement écrire vos tests dans des fichiers *.test.tsx.

Avec Vite + Vitest (stack recommandée)

Vitest est l'alternative moderne et ultra-rapide à Jest, pensée pour les projets Vite :

# Installation des dépendances
npm install --save-dev vitest jsdom @testing-library/react
npm install --save-dev @testing-library/user-event @testing-library/jest-dom
npm install --save-dev @vitejs/plugin-react

Configuration de Vitest dans vite.config.ts :

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    // Simule le DOM navigateur dans Node.js
    environment: 'jsdom',
    // Charge les matchers jest-dom automatiquement
    setupFiles: ['./src/test/setup.ts'],
    // Reconnaît les fichiers de test
    include: ['**/*.{test,spec}.{ts,tsx}'],
    // Couverture de code optionnelle
    coverage: {
      reporter: ['text', 'html'],
    },
  },
});

Fichier de setup src/test/setup.ts :

// src/test/setup.ts
// Importe les matchers personnalisés pour tester le DOM HTML
// Ex: toBeInTheDocument(), toHaveValue(), toBeVisible()...
import '@testing-library/jest-dom';

Script npm pour lancer les tests

// package.json
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}
Vitest vs Jest : Vitest utilise la même API que Jest (describe, it, expect). Si vous avez déjà des tests Jest, la migration est quasi transparente. Vitest est 5-10x plus rapide grâce à son intégration native avec Vite.

Premiers tests avec render et screen

Les deux outils fondamentaux de RTL sont render() (qui monte le composant) et screen (qui expose les queries pour trouver les éléments).

Exemple : tester un composant bouton

// src/components/Button.tsx
interface ButtonProps {
  label: string;
  disabled?: boolean;
  onClick: () => void;
}

// Composant simple à tester
export function Button({ label, disabled = false, onClick }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled} className="btn">
      {label}
    </button>
  );
}
// src/components/Button.test.tsx
import { render, screen } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  it('affiche le label correctement', () => {
    // Monte le composant dans un DOM virtuel
    render(<Button label="Envoyer" onClick={() => {}} />);

    // screen.getByText() cherche un élément par son texte
    // Lève une erreur si l'élément n'est pas trouvé
    const button = screen.getByText('Envoyer');

    // Assertion : le bouton est bien dans le DOM
    expect(button).toBeInTheDocument();
  });

  it('est désactivé quand disabled=true', () => {
    render(<Button label="Envoyer" disabled onClick={() => {}} />);

    // getByRole() est la query préférée — accessible et sémantique
    const button = screen.getByRole('button', { name: 'Envoyer' });

    // toBeDisabled() vérifie l'attribut disabled
    expect(button).toBeDisabled();
  });
});

Les queries RTL par priorité

RTL propose plusieurs types de queries. Il faut les utiliser dans cet ordre de priorité (du plus accessible au moins) :

Query Utilisation Priorité
getByRole Rôle ARIA (button, heading, textbox…) ⭐⭐⭐⭐⭐ Préférée
getByLabelText Champs de formulaire liés à un label ⭐⭐⭐⭐⭐ Préférée
getByPlaceholderText Input avec placeholder ⭐⭐⭐ Acceptable
getByText Texte visible dans le DOM ⭐⭐⭐ Acceptable
getByTestId Attribut data-testid ⭐ Dernier recours

Variantes get / query / find

Chaque query existe en trois variantes selon le comportement attendu :

// getBy* : lève une erreur si l'élément n'existe pas
// → À utiliser quand l'élément DOIT être présent
const titre = screen.getByRole('heading', { name: /bienvenue/i });

// queryBy* : retourne null si l'élément n'existe pas
// → À utiliser pour vérifier qu'un élément N'EST PAS là
const erreur = screen.queryByText('Erreur critique');
expect(erreur).not.toBeInTheDocument();

// findBy* : version async, attend que l'élément apparaisse
// → À utiliser pour les éléments qui s'affichent après un délai
const message = await screen.findByText('Chargement terminé');
expect(message).toBeVisible();
Règle d'or : Utilisez toujours getByRole en premier. Si un élément n'a pas de rôle ARIA naturel, c'est peut-être un problème d'accessibilité dans votre composant. RTL vous force à écrire du HTML sémantique !

Simuler les interactions utilisateur

Tester qu'un composant s'affiche c'est bien. Tester qu'il répond correctement aux actions de l'utilisateur, c'est encore mieux. RTL propose deux approches : fireEvent (bas niveau) et userEvent (recommandé, simule un vrai comportement navigateur).

userEvent vs fireEvent

// ❌ fireEvent : déclenche un seul événement DOM brut
// Pas de focus, pas de frappe clé par clé — trop basique
fireEvent.change(input, { target: { value: 'hello' } });

// ✅ userEvent : simule un vrai utilisateur
// Gère le focus, la saisie clé par clé, les événements intermédiaires
await userEvent.type(input, 'hello');

Exemple : compteur avec clic

// src/components/Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Compteur : {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Incrémenter</button>
      <button onClick={() => setCount(0)}>Réinitialiser</button>
    </div>
  );
}
// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  it('incrément le compteur au clic', async () => {
    // setup() crée une instance userEvent avec gestion du timing
    const user = userEvent.setup();
    render(<Counter />);

    // Récupère le bouton par son texte
    const btnIncrement = screen.getByRole('button', { name: 'Incrémenter' });

    // Simule un vrai clic utilisateur (async)
    await user.click(btnIncrement);
    await user.click(btnIncrement);
    await user.click(btnIncrement);

    // Vérifie l'affichage après 3 clics
    expect(screen.getByText('Compteur : 3')).toBeInTheDocument();
  });

  it('réinitialise le compteur', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    // Incrémente puis réinitialise
    await user.click(screen.getByRole('button', { name: 'Incrémenter' }));
    await user.click(screen.getByRole('button', { name: 'Incrémenter' }));
    await user.click(screen.getByRole('button', { name: 'Réinitialiser' }));

    // Doit revenir à 0
    expect(screen.getByText('Compteur : 0')).toBeInTheDocument();
  });
});

Tester la saisie dans un input

// src/components/SearchBar.tsx
import { useState } from 'react';

interface SearchBarProps {
  onSearch: (query: string) => void;
}

export function SearchBar({ onSearch }: SearchBarProps) {
  const [query, setQuery] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault(); // Empêche le rechargement de page
    onSearch(query);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="search">Rechercher</label>
      <input
        id="search"
        type="text"
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Tapez votre recherche..."
      />
      <button type="submit">Chercher</button>
    </form>
  );
}
// src/components/SearchBar.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBar } from './SearchBar';

describe('SearchBar', () => {
  it('appelle onSearch avec la valeur saisie', async () => {
    const user = userEvent.setup();
    // vi.fn() crée une fonction mockée (Vitest) ou jest.fn() avec Jest
    const mockOnSearch = vi.fn();

    render(<SearchBar onSearch={mockOnSearch} />);

    // getByLabelText trouve l'input via son label — priorité accessibilité
    const input = screen.getByLabelText('Rechercher');
    await user.type(input, 'react hooks');

    // Soumet le formulaire en cliquant le bouton
    await user.click(screen.getByRole('button', { name: 'Chercher' }));

    // Vérifie que la fonction callback a été appelée avec la bonne valeur
    expect(mockOnSearch).toHaveBeenCalledTimes(1);
    expect(mockOnSearch).toHaveBeenCalledWith('react hooks');
  });
});

Tester les hooks avec renderHook

Les hooks personnalisés encapsulent de la logique réutilisable. Pour les tester sans composant wrapper, RTL fournit renderHook — un utilitaire qui monte un composant minimal pour exécuter le hook et exposer son résultat.

Exemple : hook useCounter

// src/hooks/useCounter.ts
import { useState, useCallback } from 'react';

interface UseCounterOptions {
  initialValue?: number;
  min?: number;
  max?: number;
}

// Hook personnalisé avec logique de validation min/max
export function useCounter({
  initialValue = 0,
  min = -Infinity,
  max = Infinity,
}: UseCounterOptions = {}) {
  const [count, setCount] = useState(initialValue);

  // useCallback mémorise les fonctions pour éviter les re-rendus
  const increment = useCallback(() => {
    setCount(prev => Math.min(prev + 1, max));
  }, [max]);

  const decrement = useCallback(() => {
    setCount(prev => Math.max(prev - 1, min));
  }, [min]);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  return { count, increment, decrement, reset };
}
// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('initialise avec la valeur par défaut', () => {
    // renderHook monte un composant qui exécute le hook
    const { result } = renderHook(() => useCounter());

    // result.current donne accès à la valeur retournée par le hook
    expect(result.current.count).toBe(0);
  });

  it('initialise avec une valeur personnalisée', () => {
    const { result } = renderHook(() => useCounter({ initialValue: 10 }));
    expect(result.current.count).toBe(10);
  });

  it('incrémente le compteur', () => {
    const { result } = renderHook(() => useCounter());

    // act() enveloppe les actions qui mettent à jour l'état
    // Obligatoire pour que React traite les mises à jour synchroniquement
    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('respecte la limite max', () => {
    const { result } = renderHook(() => useCounter({ initialValue: 5, max: 5 }));

    act(() => {
      // Essaie d'incrémenter au-delà du max
      result.current.increment();
    });

    // Doit rester à 5 (la limite max)
    expect(result.current.count).toBe(5);
  });

  it('réinitialise à la valeur initiale', () => {
    const { result } = renderHook(() => useCounter({ initialValue: 3 }));

    act(() => {
      result.current.increment();
      result.current.increment();
    });

    expect(result.current.count).toBe(5);

    act(() => {
      result.current.reset();
    });

    // Doit revenir à 3 (valeur initiale), pas 0
    expect(result.current.count).toBe(3);
  });
});
Quand utiliser renderHook ? Uniquement pour les hooks complexes avec beaucoup de logique interne. Pour les hooks simples utilisés dans un seul composant, il est souvent plus naturel de tester directement le composant qui les utilise.

Mocker les appels API avec MSW

Les tests qui font de vraies requêtes réseau sont lents, instables et dépendants d'un serveur. La solution professionnelle est Mock Service Worker (MSW) : il intercepte les requêtes au niveau du Service Worker (en navigateur) ou avec un serveur Node (en test) et retourne des réponses simulées.

Installation MSW

# Installation de Mock Service Worker
npm install --save-dev msw

# Génère le fichier service worker pour le navigateur (optionnel en test)
npx msw init public/

Composant à tester : liste d'utilisateurs

// src/components/UserList.tsx
import { useEffect, useState } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

export function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/users')
      .then(res => {
        if (!res.ok) throw new Error('Erreur serveur');
        return res.json();
      })
      .then(data => setUsers(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>Chargement...</p>;
  if (error)   return <p role="alert">Erreur : {error}</p>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          <strong>{user.name}</strong> — {user.email}
        </li>
      ))}
    </ul>
  );
}

Configuration MSW pour les tests

// src/test/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

// Définit les handlers par défaut (réponse nominale)
export const server = setupServer(
  http.get('/api/users', () => {
    // Retourne une réponse JSON simulée
    return HttpResponse.json([
      { id: 1, name: 'Alice Martin', email: 'alice@example.com' },
      { id: 2, name: 'Bob Dupont',  email: 'bob@example.com' },
    ]);
  })
);
// src/test/setup.ts (mise à jour)
import '@testing-library/jest-dom';
import { server } from './server';

// Démarre le serveur MSW avant tous les tests
beforeAll(() => server.listen());

// Réinitialise les handlers après chaque test pour isoler les cas
afterEach(() => server.resetHandlers());

// Ferme le serveur après tous les tests
afterAll(() => server.close());

Tests avec et sans erreur

// src/components/UserList.test.tsx
import { render, screen } from '@testing-library/react';
import { server } from '../test/server';
import { http, HttpResponse } from 'msw';
import { UserList } from './UserList';

describe('UserList', () => {
  it('affiche la liste des utilisateurs', async () => {
    render(<UserList />);

    // Pendant le chargement, l'indicateur est visible
    expect(screen.getByText('Chargement...')).toBeInTheDocument();

    // findBy* attend que l'élément apparaisse (après la requête mock)
    expect(await screen.findByText('Alice Martin')).toBeInTheDocument();
    expect(screen.getByText('bob@example.com')).toBeInTheDocument();
  });

  it("affiche une erreur si l'API échoue", async () => {
    // Override le handler pour simuler une erreur serveur
    server.use(
      http.get('/api/users', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );

    render(<UserList />);

    // Attend que le message d'erreur s'affiche
    const erreur = await screen.findByRole('alert');
    expect(erreur).toHaveTextContent('Erreur : Erreur serveur');
  });
});
Pourquoi MSW plutôt que jest.mock('fetch') ? MSW intercepte au niveau réseau, pas au niveau JavaScript. Vos tests restent fidèles au comportement réel et fonctionnent aussi en navigateur (mode dev) sans modifier une seule ligne de code source.

Tester les formulaires complexes

Les formulaires sont souvent les parties les plus critiques d'une application et les plus difficiles à tester manuellement. RTL excelle dans ce domaine grâce aux queries d'accessibilité (getByLabelText, getByRole) et à userEvent qui simule une saisie réaliste.

Formulaire d'inscription avec validation

// src/components/RegisterForm.tsx
import { useState } from 'react';

interface FormData {
  email: string;
  password: string;
  confirmPassword: string;
}

interface RegisterFormProps {
  onSubmit: (data: FormData) => Promise<void>;
}

export function RegisterForm({ onSubmit }: RegisterFormProps) {
  const [form, setForm] = useState<FormData>({
    email: '',
    password: '',
    confirmPassword: '',
  });
  const [errors, setErrors] = useState<Partial<FormData>>({});
  const [submitted, setSubmitted] = useState(false);

  // Validation côté client
  const validate = (data: FormData): Partial<FormData> => {
    const errs: Partial<FormData> = {};
    if (!data.email.includes('@')) errs.email = 'Email invalide';
    if (data.password.length < 8)  errs.password = '8 caractères minimum';
    if (data.password !== data.confirmPassword) {
      errs.confirmPassword = 'Les mots de passe ne correspondent pas';
    }
    return errs;
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const errs = validate(form);
    if (Object.keys(errs).length > 0) {
      setErrors(errs);
      return;
    }
    await onSubmit(form);
    setSubmitted(true);
  };

  if (submitted) return <p>Inscription réussie !</p>;

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email"
               value={form.email} onChange={handleChange} />
        {errors.email && <span role="alert">{errors.email}</span>}
      </div>
      <div>
        <label htmlFor="password">Mot de passe</label>
        <input id="password" name="password" type="password"
               value={form.password} onChange={handleChange} />
        {errors.password && <span role="alert">{errors.password}</span>}
      </div>
      <div>
        <label htmlFor="confirmPassword">Confirmer le mot de passe</label>
        <input id="confirmPassword" name="confirmPassword" type="password"
               value={form.confirmPassword} onChange={handleChange} />
        {errors.confirmPassword && <span role="alert">{errors.confirmPassword}</span>}
      </div>
      <button type="submit">S'inscrire</button>
    </form>
  );
}
// src/components/RegisterForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RegisterForm } from './RegisterForm';

// Fonction utilitaire pour remplir le formulaire
async function fillForm(user: ReturnType<typeof userEvent.setup>, overrides = {}) {
  const defaults = {
    email: 'alice@example.com',
    password: 'motdepasse123',
    confirmPassword: 'motdepasse123',
  };
  const values = { ...defaults, ...overrides };

  await user.type(screen.getByLabelText('Email'), values.email);
  await user.type(screen.getByLabelText('Mot de passe'), values.password);
  await user.type(screen.getByLabelText('Confirmer le mot de passe'), values.confirmPassword);
}

describe('RegisterForm', () => {
  it('soumet les données valides et affiche la confirmation', async () => {
    const user = userEvent.setup();
    const mockSubmit = vi.fn().mockResolvedValue(undefined); // Simule async
    render(<RegisterForm onSubmit={mockSubmit} />);

    await fillForm(user);
    await user.click(screen.getByRole('button', { name: "S'inscrire" }));

    // Vérifie que onSubmit a reçu les bonnes données
    expect(mockSubmit).toHaveBeenCalledWith({
      email: 'alice@example.com',
      password: 'motdepasse123',
      confirmPassword: 'motdepasse123',
    });

    // Message de succès affiché
    expect(await screen.findByText('Inscription réussie !')).toBeInTheDocument();
  });

  it("affiche une erreur si l'email est invalide", async () => {
    const user = userEvent.setup();
    render(<RegisterForm onSubmit={vi.fn()} />);

    // Saisit un email invalide (sans @)
    await user.type(screen.getByLabelText('Email'), 'emailinvalide');
    await user.type(screen.getByLabelText('Mot de passe'), 'motdepasse123');
    await user.type(screen.getByLabelText('Confirmer le mot de passe'), 'motdepasse123');
    await user.click(screen.getByRole('button', { name: "S'inscrire" }));

    // Cherche le message d'erreur (role="alert" dans le JSX)
    const alertes = screen.getAllByRole('alert');
    expect(alertes[0]).toHaveTextContent('Email invalide');
  });

  it("affiche une erreur si les mots de passe ne correspondent pas", async () => {
    const user = userEvent.setup();
    render(<RegisterForm onSubmit={vi.fn()} />);

    await fillForm(user, { confirmPassword: 'different123' });
    await user.click(screen.getByRole('button', { name: "S'inscrire" }));

    expect(
      screen.getByText('Les mots de passe ne correspondent pas')
    ).toBeInTheDocument();
  });
});

Bonnes pratiques et pièges à éviter

1. Ne pas tester les détails d'implémentation

// ❌ MAUVAIS : teste l'état interne (détail d'implémentation)
// Si vous renommez le state, ce test casse sans raison valable
const { result } = renderHook(() => useToggle());
expect(result.current.isOpen).toBe(false); // Accède à la variable interne

// ✅ BON : teste le comportement visible par l'utilisateur
render(<Modal />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Ouvrir' }));
expect(screen.getByRole('dialog')).toBeVisible();

2. Un test = un comportement précis

// ❌ MAUVAIS : trop de choses dans un seul test — si ça échoue, pourquoi ?
it('le formulaire fonctionne', async () => {
  // Teste la validation ET la soumission ET la confirmation EN MÊME TEMPS
});

// ✅ BON : chaque test vérifie une seule chose
it("affiche l'erreur si l'email est vide", async () => { /* ... */ });
it('soumet avec des données valides', async () => { /* ... */ });
it('désactive le bouton pendant la soumission', async () => { /* ... */ });

3. Éviter les data-testid quand possible

// ❌ MAUVAIS : data-testid couplé à l'implémentation, pas à l'usage
<button data-testid="submit-btn">Envoyer</button>
screen.getByTestId('submit-btn');

// ✅ BON : query par rôle — test accessible ET robuste
<button type="submit">Envoyer</button>
screen.getByRole('button', { name: 'Envoyer' });

4. Wrapper avec les providers nécessaires

// Si votre composant utilise un Context ou un Router, il faut les wrapper
// Créez une fonction render personnalisée pour éviter la répétition :

// src/test/utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from '../contexts/AuthContext';

// Wrapper global avec tous les providers
function AllProviders({ children }: { children: React.ReactNode }) {
  return (
    <BrowserRouter>
      <AuthProvider>
        {children}
      </AuthProvider>
    </BrowserRouter>
  );
}

// Re-exporte render avec wrapper automatique
export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
  return render(ui, { wrapper: AllProviders, ...options });
}

// Utilisation dans les tests :
// import { renderWithProviders } from '../test/utils';
// renderWithProviders(<MonComposant />);

5. Tester l'accessibilité de base

// @axe-core/react permet de détecter automatiquement les violations ARIA
// npm install --save-dev @axe-core/react jest-axe

import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

it('respecte les règles ARIA de base', async () => {
  const { container } = render(<RegisterForm onSubmit={vi.fn()} />);
  const results = await axe(container);
  // Lance une erreur si le composant viole des règles d'accessibilité
  expect(results).toHaveNoViolations();
});
Règle du pouce : Si vous devez modifier un test parce que vous avez refactorisé l'intérieur d'un composant sans changer son comportement visible, c'est que votre test testait les mauvaises choses. Un bon test ne casse que si le comportement change.

Checklist qualité des tests React

Avant de merger une PR, passez cette checklist pour vous assurer que vos tests sont solides, maintenables et apportent une vraie valeur :

Configuration et setup

  • Vitest ou Jest configuré avec jsdom et @testing-library/jest-dom
  • Fichier setup.ts qui importe jest-dom
  • MSW configuré pour intercepter les requêtes réseau
  • Fonction renderWithProviders créée si contexts nécessaires
  • Script npm test fonctionnel en CI

Qualité des tests

  • Chaque test vérifie un seul comportement précis
  • Queries par rôle ou label (pas data-testid)
  • userEvent utilisé à la place de fireEvent
  • Tests asynchrones avec await et findBy*
  • Pas d'accès à l'état interne des composants
  • Mocks réinitialisés entre chaque test (afterEach)

Couverture des scénarios

  • Cas nominal (happy path) testé
  • Cas d'erreur (API failure, validation) testé
  • États intermédiaires (loading, disabled) testés
  • Interactions clavier testées si pertinent
  • Hooks complexes testés avec renderHook
Couverture de code ≠ qualité : Atteindre 100% de couverture en testant des détails d'implémentation est contre-productif. Visez plutôt 80% de couverture sur les comportements réels. Lancez npm run test:coverage pour visualiser les zones non testées.

Récapitulatif : stack de test recommandée

Rôle Outil Version Raison
Test runner Vitest 2.x Rapide, compatible Vite, API Jest
Render React @testing-library/react 16.x Standard de l'industrie
Interactions @testing-library/user-event 14.x Simulation réaliste utilisateur
Matchers DOM @testing-library/jest-dom 6.x Assertions HTML expressives
Mock API MSW 2.x Interception réseau réaliste
Accessibilité jest-axe 9.x Détection violations ARIA

React Testing Library a changé la façon dont l'industrie pense aux tests frontend. En forçant à tester depuis la perspective de l'utilisateur plutôt que de l'implémentation, elle produit des suites de tests plus robustes, plus maintenables, et qui donnent une vraie confiance lors des refactorisations.

Mock Service Worker (MSW) — l'approche moderne du mocking

Plutôt que de mocker fetch ou axios à coups de vi.mock(), MSW intercepte les requêtes réseau au niveau du Service Worker. Vos composants utilisent le vrai code de fetch — c'est le serveur qui est mocké.

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
    http.get('/api/users', () => {
        return HttpResponse.json([
            { id: 1, name: 'Alice' },
            { id: 2, name: 'Bob' },
        ]);
    }),
    http.post('/api/users', async ({ request }) => {
        const body = await request.json();
        return HttpResponse.json({ id: 3, ...body }, { status: 201 });
    }),
    http.get('/api/users/:id', ({ params }) => {
        if (params.id === '404') return new HttpResponse(null, { status: 404 });
        return HttpResponse.json({ id: params.id, name: 'User' });
    }),
];

// setupTests.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Override par test pour les cas d'erreur

it('affiche un message si la liste est vide', async () => {
    server.use(
        http.get('/api/users', () => HttpResponse.json([]))
    );
    render(<UserList />);
    expect(await screen.findByText(/aucun utilisateur/i)).toBeInTheDocument();
});

it('gère une erreur serveur 500', async () => {
    server.use(
        http.get('/api/users', () => new HttpResponse(null, { status: 500 }))
    );
    render(<UserList />);
    expect(await screen.findByText(/erreur/i)).toBeInTheDocument();
});

L'avantage stratégique de MSW : les mêmes handlers s'utilisent en développement (intercepter les requêtes vers une API non encore disponible), en Storybook (stories qui consomment les données), et dans les tests E2E avec Playwright. Une seule source de vérité pour le contrat d'API simulé — fini les drifts entre mocks de tests et fixtures Storybook.

Tester l'accessibilité avec jest-axe et @testing-library/jest-dom

L'accessibilité n'est plus optionnelle en 2026 — Lighthouse pénalise le SEO et plusieurs juridictions (Europe avec European Accessibility Act, USA avec ADA) rendent l'accessibilité obligatoire pour les apps publiques.

import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

it('n'a pas de violations d'accessibilité', async () => {
    const { container } = render(<UserForm />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
});

jest-axe exécute axe-core (la lib de Google/Deque) et détecte les violations WCAG 2.1 : labels manquants sur les inputs, contraste insuffisant, ARIA invalide, ordre de tabulation cassé. Ajoutez ce test sur chaque composant de formulaire — c'est typiquement là que les violations apparaissent en premier.

Matchers @testing-library/jest-dom essentiels

// Avant — fragile et peu lisible
expect(button.disabled).toBe(true);
expect(input.value).toBe('hello');
expect(div.classList.contains('active')).toBe(true);

// Après — sémantique, robuste
expect(button).toBeDisabled();
expect(input).toHaveValue('hello');
expect(div).toHaveClass('active');
expect(toast).toBeVisible();
expect(link).toHaveAttribute('href', '/about');
expect(form).toHaveFormValues({ email: 'a@b.c', agree: true });

Test des hooks personnalisés avec renderHook

Pour tester un hook isolément (sans composant wrapper), renderHook de RTL crée un environnement minimal qui rend le hook et expose son retour :

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

it('incrémente le compteur', () => {
    const { result } = renderHook(() => useCounter(10));

    expect(result.current.count).toBe(10);

    act(() => result.current.increment());
    expect(result.current.count).toBe(11);
});

it('rerender avec de nouvelles props', () => {
    const { result, rerender } = renderHook(
        ({ initial }) => useCounter(initial),
        { initialProps: { initial: 0 } }
    );

    rerender({ initial: 100 });
    expect(result.current.count).toBe(100);
});

it('utilise un wrapper pour les Context Providers', () => {
    const wrapper = ({ children }) => (
        <ThemeProvider theme="dark">{children}</ThemeProvider>
    );
    const { result } = renderHook(() => useTheme(), { wrapper });
    expect(result.current.theme).toBe('dark');
});

L'usage de renderHook est limité aux hooks complexes qui méritent un test isolé. Pour les hooks simples consommés par un seul composant, testez le composant directement — la vraie valeur d'un hook se mesure dans son usage, pas dans son comportement isolé.

Mini-projet appliqué — suite de tests d'un panier e-commerce

Cas concret : tester un panier e-commerce complet — affichage, ajout, suppression, calcul du total, code promo, soumission de commande. 5 tests d'intégration couvrent les parcours utilisateurs critiques, MSW intercepte les API, jest-axe valide l'accessibilité.

1. Setup commun avec MSW + QueryClient

Pour le pattern complet TanStack Query utilisé dans le panier, voir le guide TanStack Query.

// test-utils.tsx — wrapper de test réutilisable
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 }, mutations: { retry: false } },
    });
    return render(
        <QueryClientProvider client={queryClient}>
            <CartProvider>{ui}</CartProvider>
        </QueryClientProvider>,
        options
    );
}

// handlers.ts — handlers MSW partagés
import { http, HttpResponse } from 'msw';

export const handlers = [
    http.get('/api/cart', () => HttpResponse.json({
        items: [
            { id: 'p1', name: 'Livre TypeScript', price: 39.90, quantity: 2 },
            { id: 'p2', name: 'Mug Angular', price: 12.50, quantity: 1 },
        ],
    })),
    http.post('/api/cart/items', async ({ request }) => {
        const item = await request.json();
        return HttpResponse.json({ ...item, id: 'p3' }, { status: 201 });
    }),
    http.delete('/api/cart/items/:id', () => new HttpResponse(null, { status: 204 })),
    http.post('/api/cart/coupon', async ({ request }) => {
        const { code } = await request.json();
        if (code === 'WELCOME10') return HttpResponse.json({ discount: 0.10 });
        return HttpResponse.json({ error: 'Code invalide' }, { status: 422 });
    }),
    http.post('/api/orders', () => HttpResponse.json({ orderId: 'ORD-42' }, { status: 201 })),
];

2. Test #1 — affichage initial du panier

describe('CartPage', () => {
    it('affiche les items et le total', async () => {
        renderWithProviders(<CartPage />);

        // Attendre le chargement initial
        expect(await screen.findByRole('heading', { name: /panier/i })).toBeInTheDocument();

        // Vérifier les items affichés
        expect(screen.getByText('Livre TypeScript')).toBeInTheDocument();
        expect(screen.getByText('Mug Angular')).toBeInTheDocument();

        // Total = 39.90 * 2 + 12.50 * 1 = 92.30
        expect(screen.getByText(/92,30\s*€/)).toBeInTheDocument();
    });
});

3. Test #2 — ajout d'un item via user-event

it('ajoute un item au panier', async () => {
    const user = userEvent.setup();
    renderWithProviders(<CartPage />);

    await screen.findByText('Livre TypeScript'); // attendre le mount initial

    // Cliquer sur "Ajouter un produit"
    await user.click(screen.getByRole('button', { name: /ajouter un produit/i }));

    // Sélectionner dans la modal
    await user.click(await screen.findByRole('option', { name: /sticker/i }));
    await user.click(screen.getByRole('button', { name: /confirmer/i }));

    // L'item apparaît dans le panier après mutation
    expect(await screen.findByText(/sticker/i)).toBeInTheDocument();
});

4. Test #3 — application d'un code promo

it('applique un code promo valide', async () => {
    const user = userEvent.setup();
    renderWithProviders(<CartPage />);

    await screen.findByText('Livre TypeScript');

    const input = screen.getByLabelText(/code promo/i);
    await user.type(input, 'WELCOME10');
    await user.click(screen.getByRole('button', { name: /appliquer/i }));

    // Vérifier le message de succès
    expect(await screen.findByText(/-10\s*%/)).toBeInTheDocument();

    // Total après remise : 92,30 - 10% = 83,07
    expect(screen.getByText(/83,07\s*€/)).toBeInTheDocument();
});

it('affiche une erreur sur code promo invalide', async () => {
    const user = userEvent.setup();
    renderWithProviders(<CartPage />);

    await screen.findByText('Livre TypeScript');

    await user.type(screen.getByLabelText(/code promo/i), 'INVALID');
    await user.click(screen.getByRole('button', { name: /appliquer/i }));

    expect(await screen.findByRole('alert')).toHaveTextContent(/code invalide/i);
});

5. Test #4 — accessibilité automatisée avec jest-axe

import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

it('le panier respecte les règles WCAG 2.1 AA', async () => {
    const { container } = renderWithProviders(<CartPage />);
    await screen.findByText('Livre TypeScript');

    const results = await axe(container, {
        rules: {
            // Désactiver certaines règles si justifiées
            'color-contrast': { enabled: true },
        },
    });
    expect(results).toHaveNoViolations();
});

6. Test #5 — parcours complet de checkout

it('soumet une commande de bout en bout', async () => {
    const user = userEvent.setup();
    renderWithProviders(<CartPage />);

    await screen.findByText('Livre TypeScript');

    // Cliquer "Commander"
    await user.click(screen.getByRole('button', { name: /commander/i }));

    // Remplir le formulaire de checkout
    await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
    await user.type(screen.getByLabelText(/adresse/i), '12 rue de la Paix');
    await user.type(screen.getByLabelText(/code postal/i), '75001');

    await user.click(screen.getByRole('button', { name: /valider la commande/i }));

    // Page de confirmation avec id de commande
    expect(await screen.findByText(/ORD-42/)).toBeInTheDocument();
    expect(screen.getByText(/commande confirmée/i)).toBeInTheDocument();
});
Couverture obtenue par ces 5 tests : ~85 % des chemins critiques du panier e-commerce sont couverts en ~150 lignes de test. Comparaison : tester les mêmes parcours en E2E (Playwright/Cypress) prendrait ~3-4× plus de temps à exécuter (cold start navigator + setup DB). Pour 95 % des features, RTL + MSW suffit. Réserver E2E aux 5 % de parcours critiques business (paiement réel, OAuth login). Voir aussi la stratégie de couverture Jest + Cypress.

Pour aller plus loin sur les patterns de mocking avancés (auth mockée, WebSocket, streaming), explorer la doc officielle de Mock Service Worker. Pour les tests de hooks complexes, lire aussi la section renderHook de ce même article.

Partager