Les tests de composants en pratique

Les tests de composants sont un bon point de départ pour démontrer le code de test pratique. Les tests de composants sont plus substantiels que les tests unitaires simples, moins complexes que les tests de bout en bout et illustrent l'interaction avec le DOM. D'un point de vue plus philosophique, l'utilisation de React a permis aux développeurs Web de considérer plus facilement les sites Web ou les applications Web comme étant des composants.

Par conséquent, tester des composants individuels, quelle que soit leur complexité, est un bon moyen de commencer à penser à tester une application nouvelle ou existante.

Cette page explique comment tester un petit composant avec des dépendances externes complexes. Il est facile de tester un composant qui n'interagit avec aucun autre code, par exemple en cliquant sur un bouton et en confirmant qu'un nombre augmente. En réalité, très peu de code est semblable à cela, et tester du code sans interactions peut avoir une valeur limitée.

Il ne s'agit pas d'un tutoriel complet. Une section ultérieure, "Les tests automatisés en pratique", présentera le test d'un site réel avec un exemple de code que vous pouvez utiliser comme tutoriel. Toutefois, cette page couvre encore plusieurs exemples pratiques de tests de composants.)

Le composant testé

Nous utiliserons Vitest et son environnement JSDOM pour tester un composant React. Cela nous permet d'exécuter rapidement des tests en utilisant Node sur la ligne de commande tout en émulant un navigateur.

Liste de noms avec un bouton "Sélectionner" à côté de chacun d'eux.
Petit composant React qui affiche la liste des utilisateurs du réseau.

Ce composant React nommé UserList récupère une liste d'utilisateurs sur le réseau et vous permet de sélectionner l'un d'entre eux. La liste des utilisateurs est obtenue à l'aide de fetch dans un useEffect, et le gestionnaire de sélection est transmis par Context. Voici son code:

import React, { useEffect, useState, useContext } from 'react';
import { UserContext } from './UserContext.tsx';
import { UserRow } from './UserRow.tsx';

export function UserList({ count = 4 }: { count?: number }) {
  const [users, setUsers] = useState<any[]>([]);
  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users?_limit=' + count)
      .then((response) => response.json())
      .then((json) => setUsers(json));
  }, [count]);

  const c = useContext(UserContext);
  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users.map((u) => (
          <li key={u.id}>
            <button onClick={() => c.userChosen(u.id)}>Choose</button>{' '}
            <UserRow u={u} />
          </li>
        ))}
      </ul>
    </div>
  );
}

Cet exemple ne illustre pas les bonnes pratiques React (il utilise fetch dans useEffect, par exemple), mais votre codebase est susceptible de contenir de nombreux cas de ce type. Plus précisément, ces cas peuvent sembler difficiles à tester au premier coup d'œil. Une prochaine section de ce cours traitera de l'écriture de code testable.

Voici les éléments que nous testons dans cet exemple :

  • Vérifiez qu'un DOM correct est créé en réponse aux données du réseau.
  • Vérifiez qu'un clic sur un utilisateur déclenche un rappel.

Chaque composant est différent. En quoi le test de celui-ci est-il intéressant ?

  • Il utilise le fetch global pour demander des données réelles au réseau, qui peut être lent ou irrégulier lors des tests.
  • Elle importe une autre classe, UserRow, que nous ne souhaitons peut-être pas tester implicitement.
  • Elle utilise un Context qui ne fait pas spécifiquement partie du code testé et qui est normalement fourni par un composant parent.

Écrivez un test rapide pour commencer

Nous pouvons rapidement tester quelque chose de très basique sur ce composant. Précisons que cet exemple n'est pas très utile ! Toutefois, il est utile de configurer le code récurrent dans un fichier pair appelé UserList.test.tsx (n'oubliez pas que les exécuteurs de test tels que Vitest exécutent par défaut des fichiers se terminant par .test.js ou similaire, y compris .tsx):

import { vi, test, assert, afterAll } from 'vitest';
import { render } from '@testing-library/react';
import { UserList } from './UserList.tsx';
import React, { ContextType } from 'react';

test('render', async () => {
  const c = render(<UserList />);

  const headingNode = await c.findAllByText(/Users);
  assert.isNotNull(headingNode);
});

Ce test affirme que lorsque le composant s'affiche, il contient le texte "Users". Cela fonctionne même si le composant a pour effet secondaire d'envoyer un fetch au réseau. Le fetch est toujours en cours à la fin du test, sans point de terminaison défini. Nous ne pouvons pas confirmer l'affichage des informations utilisateur à la fin du test, du moins pas avant un délai d'inactivité.

Simulation de fetch()

La simulation consiste à remplacer une fonction ou une classe réelle par un élément sous votre contrôle pour un test. Il s'agit d'une pratique courante dans presque tous les types de tests, à l'exception des tests unitaires les plus simples. Nous aborderons ce point plus en détail dans la section Assertions et autres primitives.

Vous pouvez simuler fetch() pour votre test afin qu'il se termine rapidement et renvoie les données attendues, et non des données réelles ou inconnues. fetch est une valeur globale, ce qui signifie que nous n'avons pas besoin de l'utiliser (import) ni require dans le code.

Dans vitest, vous pouvez simuler un global en appelant vi.stubGlobal avec un objet spécial renvoyé par vi.fn(). Cela crée une simulation que nous pourrons modifier ultérieurement. Ces méthodes seront examinées plus en détail dans une section ultérieure de ce cours, mais vous pouvez les voir en pratique dans le code suivant:

test('render', async () => {
  const fetchMock = vi.fn();
  fetchMock.mockReturnValue(
    Promise.resolve({
      json: () => Promise.resolve([{ name: 'Sam', id: 'sam' }]),
    }),
  );
  vi.stubGlobal('fetch', fetchMock);

  const c = render(<UserList />);

  const headingNode = await c.queryByText(/Users);
  assert.isNotNull(headingNode);

  await waitFor(async () => {
    const samNode = await c.queryByText(/Sam);
    assert.isNotNull(samNode);
  });
});

afterAll(() => {
  vi.unstubAllGlobals();
});

Ce code ajoute une simulation, décrit une version "fausse" de la récupération réseau Response, puis attend qu'elle apparaisse. Si le texte n'apparaît pas (vous pouvez le vérifier en remplaçant la requête dans queryByText par un nouveau nom), le test échouera.

Cet exemple utilise les assistants de simulation intégrés de Vitest, mais d'autres frameworks de test ont des approches similaires pour la simulation. Vitest est unique dans la mesure où vous devez appeler vi.unstubAllGlobals() après tous les tests ou définir une option globale équivalente. Si vous ne le faites pas, la simulation fetch peut affecter d'autres tests, et chaque requête sera traitée avec notre étrange pile de JSON.

Importations fictives

Vous avez peut-être remarqué que notre composant UserList lui-même importe un composant appelé UserRow. Bien que nous n'ayons pas inclus son code, vous pouvez constater qu'il affiche le nom de l'utilisateur. Le test précédent recherche "Sam", et il n'est pas affiché directement dans UserList. Il doit donc provenir de UserRow.

Organigramme illustrant la façon dont les noms des utilisateurs se déplacent dans notre composant.
UserListTest ne peut pas voir UserRow.

Cependant, UserRow peut lui-même être un composant complexe. Il peut extraire des données utilisateur supplémentaires ou avoir des effets secondaires non pertinents pour notre test. Supprimer cette variabilité rendra vos tests plus utiles, en particulier lorsque les composants que vous souhaitez tester deviennent plus complexes et plus liés à leurs dépendances.

Heureusement, vous pouvez utiliser Vitest pour simuler certaines importations, même si votre test ne les utilise pas directement, de sorte que tout code qui les utilise soit fourni avec une version simple ou connue:

vi.mock('./UserRow.tsx', () => {
  return {
    UserRow(arg) {
      return <>{arg.u.name}</>;
    },
  }
});

test('render', async () => {
  // ...
});

Comme la simulation de fetch à l'échelle mondiale, il s'agit d'un outil puissant, mais il peut devenir instable si votre code comporte de nombreuses dépendances. Encore une fois, la meilleure solution consiste à écrire du code testable.

Cliquez pour fournir le contexte

React et d'autres bibliothèques telles que Lit ont un concept appelé Context. L'exemple de code inclut un UserContext qui appelle une méthode si un utilisateur est sélectionné. Cela est souvent considéré comme une alternative au "perçage de propulsion", où le rappel est transmis directement à UserList.

L'atelier de test que nous avons écrit n'a pas fourni UserContext. Si vous ajoutez une action de clic au test React, au pire, le test fera planter le test. Si une instance par défaut a été fournie ailleurs, cela entraînera un comportement hors de notre contrôle (semblable à un UserRow inconnu ci-dessus):

  const c = render(<UserList />);
  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();

À la place, lors du rendu du composant, vous pouvez fournir votre propre Context. Cet exemple utilise une instance de vi.fn(), une fonction Vitest de simulation, qui peut être utilisée après coup pour vérifier qu'un appel a été effectué et avec quels arguments il a été effectué. Dans notre cas, cela interagit avec la simulation de fetch dans l'exemple précédent, et le test peut confirmer que l'ID transmis est "sam":

  const userChosenFn = vi.fn();
  const ucForTest: ContextType<typeof UserContext> = { userChosen: userChosenFn as any };
  const c = render(
    <UserContext.Provider value={ucForTest}>
      <UserList />
    </UserContext.Provider>,
  );

  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();
  assert.deepStrictEqual(userChosenFn.mock.calls, [['sam']]);

Il s'agit d'un modèle simple, mais puissant qui peut vous permettre de supprimer les dépendances non pertinentes du composant principal que vous essayez de tester.

En résumé

Il s'agissait d'un exemple simple et rapide montrant comment créer un test de composant pour tester et protéger un composant React difficile à tester, en s'assurant que le composant interagit correctement avec ses dépendances (le global fetch, un sous-composant importé et un Context).

Testez vos connaissances

Quelles approches ont été utilisées pour tester le composant React ?

Simulation de dépendances complexes avec des dépendances simples à des fins de test
Injection de dépendances avec le contexte
Blocage des éléments généraux
Vérifier qu'un nombre a été incrémenté