Un guide complet pour tester les crochets React
Publié: 2022-03-11Les crochets ont été introduits dans React 16.8 fin 2018. Ce sont des fonctions qui se connectent à un composant fonctionnel et nous permettent d'utiliser des fonctionnalités d'état et de composant telles que componentDidUpdate
, componentDidMount
, etc. Ce n'était pas possible auparavant.
De plus, les crochets nous permettent de réutiliser la logique des composants et des états sur différents composants. C'était compliqué à faire avant. Par conséquent, les crochets ont changé la donne.
Dans cet article, nous allons explorer comment tester React Hooks. Nous choisirons un crochet suffisamment complexe et travaillerons à le tester.
Nous attendons de vous que vous soyez un développeur passionné de React et déjà familiarisé avec React Hooks. Si vous souhaitez approfondir vos connaissances, vous devriez consulter notre tutoriel, et voici le lien vers la documentation officielle.
Le crochet que nous utiliserons pour les tests
Pour cet article, nous utiliserons un hook que j'ai écrit dans mon article précédent, Stale-while-revalidate Data Fetching with React Hooks. Le crochet s'appelle useStaleRefresh
. Si vous n'avez pas lu l'article, ne vous inquiétez pas car je vais récapituler cette partie ici.
Voici le crochet que nous allons tester :
import { useState, useEffect } from "react"; const CACHE = {}; export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); setData(defaultValue); } // fetch new data fetch(url) .then((res) => res.json()) .then((newData) => { CACHE[cacheID] = newData; setData(newData); setLoading(false); }); }, [url, defaultValue]); return [data, isLoading]; }
Comme vous pouvez le voir, useStaleRefresh
est un hook qui permet de récupérer des données à partir d'une URL tout en renvoyant une version mise en cache des données, si elle existe. Il utilise un simple magasin en mémoire pour contenir le cache.
Il renvoie également une valeur isLoading
qui est vraie si aucune donnée ou cache n'est encore disponible. Le client peut l'utiliser pour afficher un indicateur de chargement. La valeur isLoading
est définie sur false lorsque le cache ou une nouvelle réponse est disponible.
À ce stade, je vous suggérerai de passer un peu de temps à lire le crochet ci-dessus pour bien comprendre ce qu'il fait.
Dans cet article, nous verrons comment tester ce crochet, d'abord en n'utilisant aucune bibliothèque de test (uniquement React Test Utilities et Jest), puis en utilisant react-hooks-testing-library.
La motivation derrière l'utilisation d'aucune bibliothèque de test, c'est-à-dire uniquement un lanceur de test Jest
, est de démontrer comment le test d'un hook fonctionne. Avec cette connaissance, vous serez en mesure de déboguer tout problème pouvant survenir lors de l'utilisation d'une bibliothèque qui fournit une abstraction de test.
Définition des cas de test
Avant de commencer à tester ce crochet, élaborons un plan de ce que nous voulons tester. Puisque nous savons ce que le crochet est censé faire, voici mon plan en huit étapes pour le tester :
- Lorsque le hook est monté avec l'URL
url1
,isLoading
esttrue
et data estdefaultValue
. - Après une requête de récupération asynchrone, le crochet est mis à jour avec les données
data1
etisLoading
vautfalse
. - Lorsque l'URL est remplacée par
url2
,isLoading
redevient true et data estdefaultValue
. - Après une requête de récupération asynchrone, le crochet est mis à jour avec de nouvelles données
data2
. - Ensuite, nous changeons l'URL en
url1
. Les donnéesdata1
sont instantanément reçues puisqu'elles sont mises en cache.isLoading
est faux. - Après une requête de récupération asynchrone, lorsqu'une nouvelle réponse est reçue, les données sont mises à jour vers
data3
. - Ensuite, nous changeons l'URL en
url2
. Les donnéesdata2
sont instantanément reçues puisqu'elles sont mises en cache.isLoading
est faux. - Après une requête de récupération asynchrone, lorsqu'une nouvelle réponse est reçue, les données sont mises à jour vers
data4
.
Le flux de test mentionné ci-dessus définit clairement la trajectoire de fonctionnement du crochet. Par conséquent, si nous pouvons nous assurer que ce test fonctionne, nous sommes bons.
Tester les hooks sans bibliothèque
Dans cette section, nous verrons comment tester les hooks sans utiliser de bibliothèques. Cela nous fournira une compréhension approfondie de la façon de tester React Hooks.
Pour commencer ce test, nous aimerions d'abord simuler fetch
. C'est ainsi que nous pouvons contrôler ce que l'API renvoie. Voici la fetch
.
function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }
Cette fetch
modifiée suppose que le type de réponse est toujours JSON et, par défaut, renvoie le paramètre url
comme valeur de data
. Il ajoute également un délai aléatoire compris entre 200 ms et 500 ms à la réponse.
Si nous voulons changer la réponse, nous définissons simplement le deuxième suffix
d'argument sur une valeur de chaîne non vide.
À ce stade, vous pourriez vous demander pourquoi ce retard ? Pourquoi ne renvoyons-nous pas simplement la réponse instantanément ? C'est parce que nous voulons reproduire le monde réel autant que possible. Nous ne pouvons pas tester correctement le crochet si nous le renvoyons instantanément. Bien sûr, nous pouvons réduire le délai à 50-100 ms pour des tests plus rapides, mais ne nous en soucions pas dans cet article.
Avec notre fetch mock prêt, nous pouvons le régler sur la fonction fetch
. Nous utilisons beforeAll
et afterAll
pour ce faire car cette fonction est sans état, nous n'avons donc pas besoin de la réinitialiser après un test individuel.
// runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });
Ensuite, nous devons monter le crochet dans un composant. Pourquoi? Parce que les crochets ne sont que des fonctions en soi. Ce n'est que lorsqu'ils sont utilisés dans des composants qu'ils peuvent répondre à useState
, useEffect
, etc.
Nous devons donc créer un TestComponent
qui nous aide à monter notre crochet.
// defaultValue is a global variable to avoid changing the object pointer on re-render // we can also deep compare `defaultValue` inside the hook's useEffect const defaultValue = { data: "" }; function TestComponent({ url }) { const [data, isLoading] = useStaleRefresh(url, defaultValue); if (isLoading) { return <div>loading</div>; } return <div>{data.data}</div>; }
Il s'agit d'un composant simple qui restitue les données ou restitue une invite de texte "Chargement" si les données sont en cours de chargement (en cours de récupération).
Une fois que nous avons le composant de test, nous devons le monter sur le DOM. Nous utilisons beforeEach
et afterEach
pour monter et démonter notre composant pour chaque test car nous voulons commencer avec un nouveau DOM avant chaque test.
let container = null; beforeEach(() => { // set up a DOM element as a render target container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { // cleanup on exiting unmountComponentAtNode(container); container.remove(); container = null; });
Notez que container
doit être une variable globale puisque nous voulons y avoir accès pour les assertions de test.
Avec cet ensemble, faisons notre premier test où nous rendons une URL url1
, et puisque la récupération de l'URL prendra un certain temps (voir fetchMock
), elle devrait d'abord rendre le texte de "chargement".
it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })
Exécutez le test à l'aide de yarn test
, et cela fonctionne comme prévu. Voici le code complet sur GitHub.
Maintenant, testons quand ce texte de loading
devient les données de réponse récupérées, url1
.
Comment fait-on cela? Si vous regardez fetchMock
, vous voyez que nous attendons 200 à 500 millisecondes. Et si on mettait un sleep
dans le test qui attend 500 millisecondes ? Il couvrira tous les temps d'attente possibles. Essayons ça.
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } it("useStaleRefresh hook runs correctly", async () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); await sleep(500); expect(container.textContent).toBe("url1"); });
Le test réussit, mais nous voyons également une erreur (code).
PASS src/useStaleRefresh.test.js ✓ useStaleRefresh hook runs correctly (519ms) console.error node_modules/react-dom/cjs/react-dom.development.js:88 Warning: An update to TestComponent inside a test was not wrapped in act(...).
En effet, la mise à jour de l'état dans le crochet useStaleRefresh
se produit en dehors de act(). Pour vous assurer que les mises à jour DOM sont traitées en temps opportun, React vous recommande d'utiliser act()
à chaque fois qu'un nouveau rendu ou une mise à jour de l'interface utilisateur peut se produire. Nous devons donc terminer notre sommeil avec act
car c'est le moment où la mise à jour de l'état se produit. Après cela, l'erreur disparaît.
import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));
Maintenant, exécutez-le à nouveau (code sur GitHub). Comme prévu, il passe sans erreur.
Testons la situation suivante où nous changeons d'abord l'URL en url2
, puis vérifions l'écran loading
, puis attendons la réponse de récupération et enfin vérifions le texte url2
. Puisque nous savons maintenant comment attendre correctement les changements asynchrones, cela devrait être facile.

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
Exécutez ce test, et il réussit également. Maintenant, nous pouvons également tester le cas où les données de réponse changent et le cache entre en jeu.
Vous remarquerez que nous avons un suffix
d'argument supplémentaire dans notre fonction fetchMock . C'est pour changer les données de réponse. Nous mettons donc à jour notre fetch mock pour utiliser le suffix
.
global.fetch.mockImplementation((url) => fetchMock(url, "__"));
Maintenant, nous pouvons tester le cas où l'URL est à nouveau définie sur url1
. Il charge d'abord url1
puis url1__
. Nous pouvons faire la même chose pour url2
, et il ne devrait y avoir aucune surprise.
it("useStaleRefresh hook runs correctly", async () => { // ... // new response global.fetch.mockImplementation((url) => fetchMock(url, "__")); // set url to url1 again act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("url1"); await act(() => sleep(500)); expect(container.textContent).toBe("url1__"); // set url to url2 again act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toBe("url2"); await act(() => sleep(500)); expect(container.textContent).toBe("url2__"); });
L'ensemble de ce test nous donne l'assurance que le crochet fonctionne bien comme prévu (code). Hourra! Maintenant, jetons un coup d'œil à l'optimisation à l'aide de méthodes d'assistance.
Optimisation des tests à l'aide de méthodes d'assistance
Jusqu'à présent, nous avons vu comment tester complètement notre crochet. L'approche n'est pas parfaite mais elle fonctionne. Et pourtant, peut-on faire mieux ?
Oui. Notez que nous attendons un délai fixe de 500 ms pour que chaque récupération soit terminée, mais chaque requête prend entre 200 et 500 ms. Donc, nous perdons clairement du temps ici. Nous pouvons mieux gérer cela en attendant simplement le temps que prend chaque demande.
Comment fait-on cela? Une technique simple consiste à exécuter l'assertion jusqu'à ce qu'elle passe ou qu'un délai d'attente soit atteint. Créons une fonction waitFor
qui fait cela.
async function waitFor(cb, timeout = 500) { const step = 10; let timeSpent = 0; let timedOut = false; while (true) { try { await sleep(step); timeSpent += step; cb(); break; } catch {} if (timeSpent >= timeout) { timedOut = true; break; } } if (timedOut) { throw new Error("timeout"); } }
Cette fonction exécute simplement un rappel (cb) à l'intérieur d'un bloc try...catch
toutes les 10 ms, et si le timeout
d'attente est atteint, il génère une erreur. Cela nous permet d'exécuter une assertion jusqu'à ce qu'elle passe de manière sûre (c'est-à-dire sans boucles infinies).
Nous pouvons l'utiliser dans notre test comme suit : Au lieu de dormir pendant 500 ms puis d'affirmer, nous utilisons notre fonction waitFor
.
// INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );
Faites-le dans toutes ces assertions, et nous pouvons voir une différence considérable dans la vitesse à laquelle notre test s'exécute (code).
Maintenant, tout cela est génial, mais peut-être que nous ne voulons pas tester le crochet via l'interface utilisateur. Peut-être voulons-nous tester un crochet en utilisant ses valeurs de retour. Comment fait-on cela?
Ce ne sera pas difficile car nous avons déjà accès aux valeurs de retour de notre hook. Ils sont juste à l'intérieur du composant. Si nous pouvons prendre ces variables dans la portée globale, cela fonctionnera. Alors faisons ça.
Puisque nous allons tester notre hook via sa valeur de retour et non le rendu DOM, nous pouvons supprimer le rendu HTML de notre composant et le rendre null
. Nous devrions également supprimer la déstructuration dans le retour du crochet pour le rendre plus générique. Ainsi, nous avons ce composant de test mis à jour.
// global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }
Maintenant, la valeur de retour du hook est stockée dans result
, une variable globale. Nous pouvons l'interroger pour nos assertions.
// INSTEAD OF expect(container.textContent).toContain("loading"); // WE DO expect(result[1]).toBe(true); // INSTEAD OF expect(container.textContent).toBe("url1"); // WE DO expect(result[0].data).toBe("url1");
Après l'avoir changé partout, nous pouvons voir que nos tests passent (code).
À ce stade, nous comprenons l'essentiel du test de React Hooks. Il y a quelques améliorations que nous pouvons encore apporter, telles que :
- Déplacement de la variable de
result
vers une portée locale - Suppression de la nécessité de créer un composant pour chaque crochet que nous voulons tester
Nous pouvons le faire en créant une fonction d'usine contenant un composant de test. Il devrait également afficher le crochet dans le composant de test et nous donner accès à la variable de result
. Voyons comment nous pouvons faire cela.
Tout d'abord, nous déplaçons TestComponent
et le result
à l'intérieur de la fonction. Nous devrons également passer Hook et les arguments Hook en tant qu'arguments de la fonction afin qu'ils puissent être utilisés dans notre composant de test. En utilisant cela, voici ce que nous avons. Nous appelons cette fonction renderHook
.
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }
La raison pour laquelle nous avons result
en tant qu'objet qui stocke des données dans result.current
est que nous voulons que les valeurs de retour soient mises à jour au fur et à mesure que le test s'exécute. La valeur de retour de notre crochet est un tableau, il aurait donc été copié par valeur si nous l'avions renvoyé directement. En le stockant dans un objet, nous renvoyons une référence à cet objet afin que les valeurs de retour puissent être mises à jour en mettant à jour result.current
.
Maintenant, comment allons-nous mettre à jour le crochet ? Puisque nous utilisons déjà une fermeture, incluons une autre fonction rerender
qui peut le faire.
La fonction renderHook
finale ressemble à ceci :
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } function rerender(args) { act(() => { render(<TestComponent hookArgs={args} />, container); }); } rerender(args); return { result, rerender }; }
Maintenant, nous pouvons l'utiliser dans notre test. Au lieu d'utiliser act
et render
, nous procédons comme suit :
const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);
Ensuite, nous pouvons affirmer en utilisant result.current
et mettre à jour le hook en utilisant rerender
. Voici un exemple simple :
rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true
Une fois que vous l'aurez changé à tous les endroits, vous verrez qu'il fonctionne sans aucun problème (code).
Brillant! Nous avons maintenant une abstraction beaucoup plus propre pour tester les hooks. Nous pouvons encore faire mieux - par exemple, defaultValue
doit être passé à chaque fois pour rerender
même s'il ne change pas. Nous pouvons arranger cela.
Mais ne tournons pas trop autour du pot car nous avons déjà une bibliothèque qui améliore considérablement cette expérience.
Entrez rea-hooks-testing-library.
Tester avec React-hooks-testing-library
React-hooks-testing-library fait tout ce dont nous avons parlé auparavant et plus encore. Par exemple, il gère le montage et le démontage du conteneur afin que vous n'ayez pas à le faire dans votre fichier de test. Cela nous permet de nous concentrer sur le test de nos hameçons sans nous laisser distraire.
Il est livré avec une fonction renderHook
qui renvoie rerender
et result
. Il renvoie également wait
, qui est similaire à waitFor
, vous n'avez donc pas à l'implémenter vous-même.
Voici comment nous rendons un hook dans React-hooks-testing-library. Notez que le crochet est passé sous la forme d'un rappel. Ce rappel est exécuté à chaque nouveau rendu du composant de test.
const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );
Ensuite, nous pouvons tester si le premier rendu a donné isLoading
comme true et renvoyer la valeur comme defaultValue
en procédant ainsi. Exactement similaire à ce que nous avons implémenté ci-dessus.
expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);
Pour tester les mises à jour asynchrones, nous pouvons utiliser la méthode d' wait
renvoyée par renderHook
. Il est encapsulé avec act()
donc nous n'avons pas besoin d'envelopper act()
autour de lui.
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
Ensuite, nous pouvons utiliser rerender
pour le mettre à jour avec de nouveaux accessoires. Notez que nous n'avons pas besoin de passer defaultValue
ici.
rerender({ url: "url2" });
Enfin, le reste du test se déroulera de manière similaire (code).
Emballer
Mon objectif était de vous montrer comment tester React Hooks en prenant un exemple de hook asynchrone. J'espère que cela vous aidera à tester en toute confiance tout type de crochet, car la même approche devrait s'appliquer à la plupart d'entre eux.
Je vous recommande d'utiliser React-hooks-testing-library puisqu'il est complet, et je n'ai pas rencontré de problèmes importants avec jusqu'à présent. Si vous rencontrez un problème, vous savez maintenant comment l'aborder en utilisant les subtilités des crochets de test décrits dans cet article.