Accueil / Articles

Anti-modèles de test React qui donnent une fausse confiance en 2026

Apprenez dix erreurs courantes de test avec React – allant de l’utilisation incorrecte de fireEvent aux pièges liés à la simulation du réseau – qui permettent à des fonctionnalités défectueuses de passer inaperçues malgré un ensemble de tests entièrement réussi.

Éditeur : REACTAPP.TOP14 min de lecture3125 mots

Sources: 10 React Testing Mistakes That Are Secretly Making Your Tests Useless in 2026

Votre suite de tests montre un résultat complet avec toutes les vérifications réussies, et vous lancez la version en production en étant satisfait. Puis la file d’attente du support se remplit : le bouton de paiement échoue silencieusement sur les appareils mobiles, l’écran de connexion affiche une erreur lorsque l’API répond lentement, et les utilisateurs qui travaillent avec un clavier ne parviennent pas du tout à naviguer dans le menu. Rien dans vos tests n’a détecté aucun de ces problèmes. Alors, que s’est-il passé ?

D’ici 2026, avec l’utilisation de React 19 en même temps que le React Compiler et la Testing Library v16, l’écart entre une suite de tests qui se contente de valider les fonctionnalités et une autre qui protège réellement votre application s’est encore élargi. Voici dix habitudes qui font croire que vos tests sont exhaustifs, alors que votre application en production continue malgré tout de planter silencieusement.

1. Tester les détails d’implémentation plutôt que le comportement de l’utilisateur

C’est le péché classique des tests React, et on continue de commettre cette erreur constamment. Un composant est rendu, ses parties internes sont modifiées, et les assertions vérifient par exemple si useState a été appelé ou si une propriété particulière a été transmise. Ce n’est pas vérifier ce que vit réellement l’utilisateur — c’est vérifier comment le développeur l’a construit.

// ❌ Useless: Testing internals
import { render } from '@testing-library/react';
import { LoginForm } from './LoginForm';
test('sets isSubmitting to true on submit', () => {
  const wrapper = render(<LoginForm />);
  const instance = wrapper.container.firstChild; // Reaching into internals

  // This tests React, not your app
  expect(instance).toHaveProperty('isSubmitting');
});

Un tel test est fragile et inutile : changer le nom d’une variable, remplacer useState par useReducer, ou extraire la logique dans un hook personnalisé, et il cesse de fonctionner — même si rien dans le flux d’authentification réel n’a changé. Il ne donne aucune indication sur la possibilité pour une personne de se connecter.

// ✅ Useful: Testing behavior
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('submits the form with user credentials', async () => {
  const handleSubmit = vi.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  const user = userEvent.setup();

  await user.type(screen.getByLabelText(/email/i), 'test@example.com');
  await user.type(screen.getByLabelText(/password/i), 'secret123');
  await user.click(screen.getByRole('button', { name: /sign in/i }));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'secret123',
  });
});

Le principe fondamental de Testing Library reste valable en 2026 : des tests qui reflètent une utilisation réelle vous donnent une confiance plus authentique. Si un simple refactoring peut casser vos tests, vous effectuez des assertions au mauvais niveau.

2. Utiliser fireEvent au lieu de userEvent

fireEvent ne fait que déclencher des événements DOM bruts. userEvent, quant à lui, reproduit toute la séquence d’actions effectuées par une personne réelle. Il n’y a presque plus de raison d’utiliser fireEvent dans les tests de composants, mais cette pratique persiste car c’est ce que préconisaient anciennement les guides.

// ❌ Useless: fireEvent does not simulate real interaction
test('toggles the menu', () => {
  render(<Navigation />);
  const button = screen.getByText('Menu');

  fireEvent.click(button); // Just fires a click event

  expect(screen.getByText('Dashboard')).toBeInTheDocument();
});

Le problème, c’est que fireEvent.click ne confirme jamais que l’élément peut réellement être cliqué par un humain. Il peut être désactivé, recouvert par un autre élément, ou complètement inaccessible via le clavier. L’assertion réussit alors que la fonctionnalité réelle reste inutilisable.

// ✅ Useful: userEvent validates the interaction
test('toggles the menu with keyboard and mouse', async () => {
  render(<Navigation />);
  const user = userEvent.setup();
  const button = screen.getByRole('button', { name: /menu/i });

  // Verifies element is focusable and clickable
  await user.click(button);
  expect(screen.getByRole('link', { name: /dashboard/i })).toBeVisible();

  // Tests keyboard accessibility
  await user.keyboard('{Escape}');
  expect(screen.queryByRole('link', { name: /dashboard/i })).not.toBeInTheDocument();
});

userEvent vérifie d’abord que la cible n’est pas désactivée ou en mode lecture seule et qu’elle est bien visible avant de déclencher quoi que ce soit, puis il simule la séquence réaliste des événements de pointeur, de clavier et de focus. L’accessibilité n’est plus une option supplémentaire ; vos tests doivent donc la prendre en compte automatiquement.

3. Simulation des appels réseau au mauvais niveau

Supposons que vous testiez un composant qui récupère des données depuis une API. La solution rapide consiste à créer des faux objets pour fetch ou axios.get directement dans le fichier de test. Les tests passent alors. Par la suite, l’équipe backend renomme un champ de userName en username, ce qui provoque des problèmes en production, sans que votre suite de tests ne s’en aperçoive.

// ❌ Useless: Mocking fetch directly
test('displays user profile', async () => {
  global.fetch = vi.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ userName: 'Alice' }), // Drifts from reality
    })
  );

  render(<UserProfile userId="123" />);

  expect(await screen.findByText('Alice')).toBeInTheDocument();
});

Le problème ici est que vous effectuez des validations en utilisant une API fictive, et non la vraie. Le stub ne vérifie pas les en-têtes, n’analyse pas la structure de l’URL, et ne simule ni les retards ni les pannes. Il vous donne une fausse impression de sécurité tandis que l’intégration réelle s’éloigne progressivement de la synchronisation.

// ✅ Useful: Mocking at the network layer with MSW
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ username: 'Alice', id: params.id });
  })
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('displays user profile from API', async () => {
  render(<UserProfile userId="123" />);

  // Tests the actual component + network layer together
  expect(await screen.findByText(/alice/i)).toBeInTheDocument();
  expect(screen.getByText(/id: 123/i)).toBeInTheDocument();
});

MSW intervient au niveau de la frontière réseau, de sorte que votre composant suit exactement le même chemin d’appel fetch qu’il utiliserait en production. Lorsque le contrat de l’API change, vous modifiez le gestionnaire MSW en un seul endroit, et tous les tests ciblant cet endpoint reflètent automatiquement la nouvelle réalité. D’ici 2026, MSW 2.x est devenu le choix par défaut, et l’utilisation directe de stubs pour fetch représente essentiellement une dette technique accumulée.

4. S’appuyer sur des captures d’écran comme substitut aux tests de régression

Les tests d’aperçu sont tentants justement parce qu’ils ne demandent pas beaucoup de travail. Vous générez du contenu, vous capturez l’HTML résultant et vous le soumettez. Des mois plus tard, un designer modifie un module CSS, l’aperçu cesse de fonctionner, et quelqu’un approuve le nouveau fichier d’aperçu avec une simple touche, sans jamais vraiment lire ce qui a changé.

// ❌ Useless: Snapshot as a crutch
test('renders correctly', () => {
  const { container } = render(<Card title="Hello" />);
  expect(container.firstChild).toMatchSnapshot();
});

Le problème, c’est que personne ne examine réellement une différence de 400 lignes de markup serialisé. L’aperçu indique que quelque chose a changé, mais il ne peut pas vous dire si ce changement est une simple modification de design inoffensive ou un problème de mise en page. C’est du bruit présenté comme une sécurité.

// ✅ Useful: Targeted assertions
test('renders card with accessible title and action', () => {
  render(<Card title="Hello" onAction={() => {}} />);

  expect(screen.getByRole('heading', { name: /hello/i })).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /action/i })).toBeEnabled();
  expect(screen.getByRole('article')).toHaveAttribute('aria-labelledby');
});

En utilisant des assertions ciblées, on décrit précisément le comportement promis par le composant. Si la structure du markup change mais que les contraintes d’accessibilité restent respectées, le test continue de passer. Si un rôle de titre est supprimé par erreur, un message d’erreur précis indique le véritable problème. À l’aube de 2026, il est judicieux de supprimer complètement vos fichiers de snapshot et de les remplacer par des assertions qui expriment vos attentes réelles.

5. Sélectionner des éléments exclusivement via data-testid

Utiliser data-testid sur chaque élément semble être l’option sûre et fiable — la requête trouve toujours sa cible. Cependant, un sélecteur basé sur data-testid ne confirme rien concernant l’accessibilité, la sémantique ou la manière dont un utilisateur réel interagit avec l’interface. Tout ce qu’il confirme, c’est que le développeur s’est souvenu de saisir une chaîne de caractères spécifique.

// ❌ Useless: Brittle, tests nothing about accessibility
function Form() {
  return (
    <div data-testid="form-container">
      <input data-testid="email-input" />
      <button data-testid="submit-btn">Submit</button>
    </div>
  );
}
test('submits form', async () => {
  render(<Form />);
  await userEvent.type(screen.getByTestId('email-input'), 'test@example.com');
  await userEvent.click(screen.getByTestId('submit-btn'));
});

Ce type de test passe sans problème même lorsque l’entrée manque d’étiquette, que le « bouton » n’est en réalité qu’un div stylisé, et que l’utilisateur d’un lecteur d’écran serait complètement perdu. En fait, il certifie que du markup défectueux est considéré comme correct.

// ✅ Useful: Semantic queries enforce accessibility
function Form() {
  return (
    <form aria-label="Login form">
      <label htmlFor="email">Email address</label>
      <input id="email" type="email" />
      <button type="submit">Submit</button>
    </form>
  );
}
test('submits login form', async () => {
  render(<Form />);
  const user = userEvent.setup();

  // These queries fail if the elements are not accessible
  await user.type(screen.getByLabelText(/email address/i), 'test@example.com');
  await user.click(screen.getByRole('button', { name: /submit/i }));

  expect(screen.getByRole('form', { name: /login form/i })).toBeInTheDocument();
});

L’ordre de recherche recommandé par Testing Library n’est pas arbitraire : getByRole, getByLabelText et getByPlaceholderText vous orientent vers un markup véritablement accessible. Si la seule façon d’obtenir un élément est via un data-testid, c’est généralement le signe que le composant lui-même n’est pas accessible. Avec l’augmentation des litiges liés à l’accessibilité gérés par l’IA d’ici 2026, cela cesse d’être un simple raccourci de test pour devenir une véritable exposition juridique.

6. Omettre l’attente des tâches asynchrones

Presque tout dans React s’exécute avec un délai. Les changements d’état sont regroupés, les effets s’exécutent après que le navigateur ait affiché l’interface, et la récupération de données ne se termine qu’à un moment ultérieur. Si un test n’attend pas correctement que ces opérations soient terminées, ses assertions s’exécutent prématurément et le test passe sans vérifier réellement quoi que ce soit d’important.

// ❌ Useless: Missing await on async operations
test('shows search results', () => {
  render(<Search />);

  userEvent.type(screen.getByRole('searchbox'), 'react');
  userEvent.keyboard('{Enter}');

  // This runs before the fetch resolves
  expect(screen.getByText('React Testing')).toBeInTheDocument();
});

En fonction du mécanisme de mise en cache ou des délais, un tel test peut passer par chance ou échouer de manière imprévisible. Cela est sans doute pire que de ne pas avoir de test du tout, car cela crée une fausse sensation de sécurité.

// ✅ Useful: Explicitly await async states
test('shows search results after typing', async () => {
  render(<Search />);
  const user = userEvent.setup();

  await user.type(screen.getByRole('searchbox'), 'react');
  await user.keyboard('{Enter}');

  // findBy queries wait for the element to appear
  expect(await screen.findByText('React Testing')).toBeInTheDocument();

  // Ensure loading state was shown and removed
  expect(screen.queryByRole('status', { name: /loading/i })).not.toBeInTheDocument();
});

En entrant dans l’année 2026, avec le hook use de React 19 et une utilisation plus large des limites Suspense, les patterns asynchrones apparaissent partout. Toute interaction qui déclenche une mise à jour d’état, une transition ou une requête réseau doit être attendue correctement. Utilisez les requêtes findBy lorsque un élément apparaît de manière asynchrone, employez waitFor pour des conditions allant au-delà de simples vérifications de présence, et assurez-vous que chaque appel userEvent soit attendu.

7. Tester les composants en isolation par rapport à leur environnement réel

Il est courant de rendre un composant complètement seul — sans routeur, sans fournisseur de thème, sans client de requêtes, sans contexte d’authentification — de voir le test réussir, puis de constater que ce même composant échoue en production parce qu’il dépendait silencieusement d’un contexte que le test n’a jamais fourni.

// ❌ Useless: Missing app context
test('renders dashboard', () => {
  render(<Dashboard />);
  expect(screen.getByText('Welcome')).toBeInTheDocument();
});

Ce test ne vous en dit pas beaucoup, car Dashboard dépend probablement de fonctions comme useQuery, useAuth ou useNavigate. Sans les fournisseurs correspondants en place, vous ne testez pas réellement le composant — vous testez plutôt une version imaginaire qui n’a aucun équivalent dans votre application réelle.

// ✅ Useful: Custom render with all providers
import { render as rtlRender } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
function render(ui, { route = '/', ...options } = {}) {
  window.history.pushState({}, 'Test page', route);

  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  const Wrapper = ({ children }) => (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <AuthProvider>
          {children}
        </AuthProvider>
      </BrowserRouter>
    </QueryClientProvider>
  );

  return rtlRender(ui, { wrapper: Wrapper, ...options });
}
// Now your tests run in an environment that matches production
test('renders dashboard for authenticated user', async () => {
  render(<Dashboard />, { route: '/dashboard' });

  expect(await screen.findByText(/welcome back/i)).toBeInTheDocument();
});

Étant donné le caractère fortement contextuel des architectures modernes, ainsi que la popularité de couches de mise en cache comme React Query, créer un outil d’affichage personnalisé partagé cesse d’être un plus à partir de 2026 — il devient le pilier de toute votre solution. Chaque test doit utiliser la même série de fournisseurs que celle utilisée réellement dans votre application en production.

8. Ne couvrir que le parcours réussi

Vérifier que un formulaire est soumis avec des données valides, qu’une liste s’affiche lorsque l’API renvoie des données, ou qu’un bouton réagit lorsqu’il est activé n’est pas en soi une erreur — mais s’arrêter là rend votre couverture de tests très incomplète, et des tests incomplets échouent précisément lorsque des pannes surviennent.

// ❌ Useless: Only the sunny day scenario
test('displays products', async () => {
  server.use(
    http.get('/api/products', () => HttpResponse.json([{ id: 1, name: 'Widget' }]))
  );

  render(<ProductList />);
  expect(await screen.findByText('Widget')).toBeInTheDocument();
});

Ce type de test ne dit rien sur une réponse 500, un ensemble de résultats vide, une requête dépassant le délai imparti, ou un utilisateur soumettant des données incorrectes. Une grande partie des bugs réels en environnement de production — souvent estimée à environ quatre-vingts pour cent — se cachent précisément dans ces cas limites.

// ✅ Useful: Testing states that actually happen to users
test('displays empty state when no products exist', async () => {
  server.use(http.get('/api/products', () => HttpResponse.json([])));

  render(<ProductList />);
  expect(await screen.findByText(/no products available/i)).toBeInTheDocument();
});
test('displays error state when API fails', async () => {
  server.use(
    http.get('/api/products', () => new HttpResponse(null, { status: 500 }))
  );

  render(<ProductList />);
  expect(await screen.findByRole('alert')).toHaveTextContent(/failed to load/i);
});
test('prevents submission with invalid email', async () => {
  render(<NewsletterForm />);
  const user = userEvent.setup();

  await user.type(screen.getByLabelText(/email/i), 'not-an-email');
  await user.click(screen.getByRole('button', { name: /subscribe/i }));

  expect(screen.getByRole('alert')).toHaveTextContent(/valid email required/i);
  expect(screen.getByRole('button')).toBeDisabled(); // Submit blocked
});

9. Permettre à l’état de se propager entre les cas de test

L’état global partagé constitue une source récurrente de problèmes dans les suites de tests. Un stockage Zustand peut conserver des valeurs d’un test à l’autre. Le cache TanStack Query peut retenir des entrées obsolètes. Une valeur de window.location simulée dans un fichier peut influencer discrètement le suivant. Le résultat est une suite où les tests passent correctement individuellement mais échouent lorsqu’ils sont exécutés ensemble — ou, de manière encore plus déroutante, passent dans le cadre d’un exécution complète mais échouent lorsqu’ils sont exécutés isolément.

// ❌ Useless: Polluted global state
import { useAuthStore } from './store';
test('logs in user', () => {
  useAuthStore.getState().setUser({ id: 1, name: 'Alice' });
  render(<Profile />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
});
// This test might fail depending on run order
test('shows guest view', () => {
  render(<Profile />);
  // If the previous test ran first, Alice is still logged in
  expect(screen.getByText(/sign in/i)).toBeInTheDocument();
});

La conséquence est une suite de tests instable, lente et peu fiable. Une fois cela arrivé, les ingénieurs cessent de faire confiance aux échecs des tests automatisés, et ces derniers deviennent un bruit de fond que personne n’investigue.

// ✅ Useful: Isolated state for every test
import { act } from '@testing-library/react';
beforeEach(() => {
  // Reset Zustand store
  act(() => {
    useAuthStore.setState({ user: null, isAuthenticated: false });
  });

  // Clear React Query cache
  queryClient.clear();

  // Reset MSW handlers
  server.resetHandlers();

  // Reset mocks
  vi.clearAllMocks();
});
test('logs in user', async () => {
  // Test with explicit setup
  act(() => {
    useAuthStore.getState().setUser({ id: 1, name: 'Alice' });
  });

  render(<Profile />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
});
test('shows guest view', () => {
  // Guaranteed clean state
  render(<Profile />);
  expect(screen.getByText(/sign in/i)).toBeInTheDocument();
});

En entrant dans l’année 2026, avec des bibliothèques d’état atomique et des caches partagés partout, il est essentiel de considérer l’isolation des tests comme une question architecturale et non comme une solution ajoutée en dernier recours. Chaque test doit créer son propre contexte propre, vérifier son comportement, puis tout détruire par la suite. Un bon test de diagnostic : si votre suite ne peut pas fonctionner lorsqu’elle est exécutée dans un ordre aléatoire, quelque chose dans votre stratégie d’isolation est défaillant.

10. Affirmations qui ne peuvent jamais échouer

Peut-être que la plus subtile des erreurs consiste à écrire un test qui semble valide mais qui est structurellement incapable de détecter une erreur. Cela se produit généralement lorsque l’affirmation vise le mauvais élément, lorsqu’un await est omis, ou lorsque l’on utilise un comparateur trop indulgent.

// ❌ Useless: Assertion that cannot fail
test('modal opens', async () => {
  render(<Modal trigger={<button>Open</button>} />);

  // getBy throws if not found, but this is not an assertion
  screen.getByRole('dialog');

  // This passes even if the modal is empty or hidden
  expect(screen.getByRole('dialog')).toBeTruthy();
});
// ❌ Useless: Missing await on expect
test('shows notification', async () => {
  render(<Notifier />);
  await userEvent.click(screen.getByRole('button'));

  // Without await, this checks before the notification appears
  expect(screen.findByText(/success/i)).toBeInTheDocument();
});
// ❌ Useless: Testing presence of truthy object
test('form is valid', () => {
  const { container } = render(<Form />);
  // container is always truthy
  expect(container).toBeTruthy();
});

De tels tests sont sans doute pires que l’absence totale de tests — ils consomment toujours des minutes dans les processus d’intégration continue et donnent à l’équipe une fausse sensation de confiance, tout en étant incapables d’indiquer une véritable régression lorsqu’elle se produit.

// ✅ Useful: Specific, awaited, negative assertions
test('modal opens and traps focus', async () => {
  render(<Modal trigger={<button>Open</button>} />);
  const user = userEvent.setup();

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

  // Assert visibility, not just presence
  const modal = screen.getByRole('dialog', { name: /confirm/i });
  expect(modal).toBeVisible();

  // Assert focus management
  expect(screen.getByRole('button', { name: /close/i })).toHaveFocus();

  // Assert escape key behavior
  await user.keyboard('{Escape}');
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('shows success notification after action', async () => {
  render(<Notifier />);
  const user = userEvent.setup();

  await user.click(screen.getByRole('button'));

  // await the findBy, then assert
  const notification = await screen.findByRole('alert');
  expect(notification).toHaveTextContent(/successfully saved/i);
  expect(notification).toHaveAttribute('aria-live', 'polite');
});

Compte tenu de l’orientation continue de React Testing Library vers la vérification visuelle pour l’utilisateur, faire appel à des outils de comparaison peu précis tels que toBeTruthy ou toBeDefined devrait être considéré comme un signe d’alerte en 2026. Préférer les assertions basées sur le texte visible, les attributs ARIA, l’état désactivé et ce qui est réellement affiché. Un bon critère rapide : si l’assertion passerait encore pour un div vide, elle doit être réécrite.

La liste de contrôle des tests pour 2026

Au préalable de fusionner un nouveau test, passez en revue cette liste :

  • [ ] Vérifie-t-il le comportement visible pour l’utilisateur plutôt que l’implémentation interne ?
  • [ ] Stimule-t-il les interactions avec userEvent plutôt qu’avec fireEvent ?
  • [ ] La couche réseau est-elle simulée à l’aide de MSW plutôt qu’avec un stub brut fetch ?
  • [ ] Formule-t-il des assertions délibérées au lieu de se fier à des captures d’écran ?
  • [ ] Les éléments sont-ils localisés en fonction de leur rôle et de leur étiquette plutôt que grâce à data-testid ?
  • [ ] Tous les étapes asynchrones sont-elles correctement attendues ?
  • [ ] Affiche-t-il l’interface à l’intérieur de la même hiérarchie de fournisseurs que celle utilisée par l’application réelle ?
  • [ ] Teste-t-il les états d’erreur, vides et de chargement, et pas seulement le scénario normal ?
  • [ ] L’état global est-il réinitialisé afin que le test puisse s’exécuter dans n’importe quel ordre ?
  • [ ] L’assertion échouerait-elle réellement si le comportement sous-jacent venait à se dégrader ?
  • Récupérer simplement une coche verte de votre suite de tests n’est pas l’objectif réel — ce qui compte, c’est d’avoir la certitude de pouvoir faire confiance à cette coche. En évitant ces dix pièges, vos tests passeront d’une simple formalité à un véritable filet de sécurité tel qu’ils devraient l’être.

    Lectures complémentaires