React LogoArchitecture front-end
Architecture/Testing

Tester les composants

Tests de composants React avec Testing Library

Les tests unitaires couvrent la logique pure, mais dans une application React, la majeure partie du code vit dans des composants. Il faut pouvoir vérifier qu'un composant s'affiche correctement et réagit aux interactions de l'utilisateur.

C'est le rôle de React Testing Library. Sa philosophie est simple : tester les composants comme un utilisateur les utiliserait, pas comme un développeur les a codés. On ne teste pas l'implémentation interne (l'état, les refs, les hooks), on teste le rendu visible et les interactions.

Monter un composant

La fonction render de Testing Library monte votre composant dans un DOM simulé (jsdom). Ensuite, on utilise screen pour chercher des éléments comme le ferait un utilisateur : par le texte visible, les labels, les rôles ARIA.

import { render, screen } from '@testing-library/react';
import { ProductCard } from './ProductCard';

describe('ProductCard', () => {
  it('devrait afficher le nom et le prix du produit', () => {
    render(
      <ProductCard
        product={{ id: '1', name: 'T-shirt', price: 29.99, imageUrl: '/img.jpg' }}
      />
    );

    expect(screen.getByText('T-shirt')).toBeInTheDocument();
    expect(screen.getByText('29,99 €')).toBeInTheDocument();
  });
});

Simuler les interactions

Pour simuler un clic, une saisie de texte ou toute autre interaction, on utilise userEvent. C'est plus réaliste que fireEvent car il simule le comportement complet de l'utilisateur (focus, frappe caractère par caractère, etc.).

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('AddToCartButton', () => {
  it('devrait appeler onAdd au clic', async () => {
    const mockOnAdd = vi.fn();
    render(<AddToCartButton onAdd={mockOnAdd} />);

    const button = screen.getByRole('button', { name: /ajouter/i });
    await userEvent.click(button);

    expect(mockOnAdd).toHaveBeenCalledOnce();
  });
});

Quelques points importants :

  • On utilise getByRole avec un nom accessible plutôt que de chercher par classe CSS ou par data-testid. C'est la manière recommandée.
  • vi.fn() crée une fonction mock de Vitest qu'on peut ensuite inspecter (nombre d'appels, arguments, etc.).
  • userEvent est asynchrone, d'où le async/await.

Tester le rendu conditionnel

Un cas très courant : vérifier qu'un composant affiche ou masque des éléments en fonction de son état.

describe('StockBadge', () => {
  it('devrait afficher "En stock" quand le stock est positif', () => {
    render(<StockBadge stock={5} />);
    expect(screen.getByText('En stock')).toBeInTheDocument();
  });

  it('devrait afficher "Rupture de stock" quand le stock est à zéro', () => {
    render(<StockBadge stock={0} />);
    expect(screen.getByText('Rupture de stock')).toBeInTheDocument();
  });

  it('ne devrait pas afficher le bouton d\'achat en rupture', () => {
    render(<StockBadge stock={0} />);
    expect(screen.queryByRole('button', { name: /acheter/i })).not.toBeInTheDocument();
  });
});

La différence entre getByText et queryByText est importante :

  • getBy... lève une erreur si l'élément n'existe pas (utile quand on sait qu'il devrait être là).
  • queryBy... retourne null si l'élément n'existe pas (utile pour vérifier l'absence d'un élément).

Tester un composant qui utilise un store Zustand

Si votre composant consomme un store Zustand, vous n'avez rien de spécial à configurer. Pas de Provider à envelopper. Il suffit d'initialiser le store dans l'état souhaité avant le test :

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useCartStore } from '@/store/useCartStore';
import { CartBadge } from './CartBadge';

describe('CartBadge', () => {
  beforeEach(() => {
    useCartStore.setState({ items: [] });
  });

  it('devrait afficher le nombre d\'articles', () => {
    useCartStore.setState({
      items: [
        { id: '1', name: 'T-shirt', price: 29.99 },
        { id: '2', name: 'Casquette', price: 15 },
      ],
    });

    render(<CartBadge />);
    expect(screen.getByText('2')).toBeInTheDocument();
  });

  it('ne devrait rien afficher si le panier est vide', () => {
    render(<CartBadge />);
    expect(screen.queryByText('0')).not.toBeInTheDocument();
  });
});

C'est un des avantages de Zustand par rapport au Context API : pas de Provider à wrapper autour du composant dans les tests.

Tester un composant avec React Query

Les composants qui utilisent React Query ont besoin d'un QueryClientProvider. On crée un utilitaire de rendu dédié pour éviter de le répéter partout :

/* test/renderWithProviders.tsx */
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export function renderWithProviders(ui: React.ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false }, /* Pas de retry dans les tests */
    },
  });

  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}
import { renderWithProviders } from '@/test/renderWithProviders';
import { ProductList } from './ProductList';

describe('ProductList', () => {
  it('devrait afficher un loader pendant le chargement', () => {
    renderWithProviders(<ProductList />);
    expect(screen.getByText(/chargement/i)).toBeInTheDocument();
  });
});

Les bonnes pratiques

Quelques règles à garder en tête pour écrire des tests maintenables :

  • Testez le comportement, pas l'implémentation. Ne vérifiez jamais qu'un useState a une certaine valeur. Vérifiez que le texte affiché à l'écran est correct.
  • Utilisez les rôles et labels accessibles (getByRole, getByLabelText) plutôt que les data-testid ou les sélecteurs CSS. C'est plus proche de l'expérience utilisateur et ça vous pousse à écrire du HTML accessible.
  • Un test = un comportement. Chaque it devrait tester une seule chose précise.
  • Nommez vos tests comme des phrases. 'devrait afficher le prix en euros' est plus lisible que 'test price formatting'.

Avec FSD

Dans une architecture FSD, les fichiers de test se placent à côté du fichier qu'ils testent, avec le suffixe .test.ts ou .test.tsx :

entities/product/
├── model/
│   ├── productSchema.ts
│   └── productSchema.test.ts     /* Test unitaire du schéma */
├── ui/
│   ├── ProductCard.tsx
│   └── ProductCard.test.tsx      /* Test de composant */
└── index.ts

L'idée c'est de rendre les tests faciles à trouver et à maintenir. Quand on modifie un fichier, son test est juste à côté.