Una guida completa per testare i ganci React
Pubblicato: 2022-03-11Gli hook sono stati introdotti in React 16.8 alla fine del 2018. Sono funzioni che si agganciano a un componente funzionale e ci consentono di utilizzare funzionalità di stato e componenti come componentDidUpdate
, componentDidMount
e altro ancora. Questo non era possibile prima.
Inoltre, gli hook ci consentono di riutilizzare la logica dei componenti e degli stati tra diversi componenti. Questo era difficile da fare prima. Pertanto, gli hook sono stati un punto di svolta.
In questo articolo, esploreremo come testare React Hooks. Sceglieremo un hook sufficientemente complesso e lavoreremo per testarlo.
Ci aspettiamo che tu sia un appassionato sviluppatore React che abbia già familiarità con React Hooks. Nel caso in cui desideri rispolverare le tue conoscenze, dovresti dare un'occhiata al nostro tutorial, ed ecco il link alla documentazione ufficiale.
Il gancio che useremo per i test
Per questo articolo, utilizzeremo un hook che ho scritto nel mio precedente articolo, Recupero dati obsoleto durante il rinnovo con React Hooks. L'hook si chiama useStaleRefresh
. Se non hai letto l'articolo, non preoccuparti perché ricapitolerò questa parte qui.
Questo è il gancio che testeremo:
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]; }
Come puoi vedere, useStaleRefresh
è un hook che aiuta a recuperare i dati da un URL restituendo una versione memorizzata nella cache dei dati, se esiste. Utilizza un semplice archivio in memoria per conservare la cache.
Restituisce anche un valore isLoading
che è vero se non sono ancora disponibili dati o cache. Il client può usarlo per mostrare un indicatore di caricamento. Il valore isLoading
è impostato su false quando è disponibile una cache o una nuova risposta.
A questo punto, ti suggerirò di dedicare un po' di tempo alla lettura del gancio sopra per avere una comprensione completa di ciò che fa.
In questo articolo, vedremo come possiamo testare questo hook, prima non usando librerie di test (solo React Test Utilities e Jest) e poi usando react-hooks-testing-library.
La motivazione dietro l'utilizzo di nessuna libreria di test, cioè solo un test runner Jest
, è dimostrare come funziona il test di un hook. Con questa conoscenza, sarai in grado di eseguire il debug di eventuali problemi che potrebbero sorgere quando si utilizza una libreria che fornisce l'astrazione dei test.
Definizione dei casi di test
Prima di iniziare a testare questo hook, elaboriamo un piano di ciò che vogliamo testare. Dal momento che sappiamo cosa dovrebbe fare l'hook, ecco il mio piano in otto fasi per testarlo:
- Quando l'hook è montato con l'URL
url1
,isLoading
ètrue
e data èdefaultValue
. - Dopo una richiesta di recupero asincrono, l'hook viene aggiornato con data
data1
eisLoading
èfalse
. - Quando l'URL viene modificato in
url2
,isLoading
diventa nuovamente true e data èdefaultValue
. - Dopo una richiesta di recupero asincrono, l'hook viene aggiornato con i nuovi dati
data2
. - Quindi, cambiamo nuovamente l'URL in
url1
. I datidata1
vengono ricevuti istantaneamente poiché sono memorizzati nella cache.isLoading
è falso. - Dopo una richiesta di recupero asincrono, quando viene ricevuta una nuova risposta, i dati vengono aggiornati in
data3
. - Quindi, cambiamo nuovamente l'URL in
url2
. I datidata2
vengono ricevuti istantaneamente poiché sono memorizzati nella cache.isLoading
è falso. - Dopo una richiesta di recupero asincrono, quando viene ricevuta una nuova risposta, i dati vengono aggiornati in
data4
.
Il flusso di prova sopra menzionato definisce chiaramente la traiettoria di funzionamento del gancio. Pertanto, se possiamo garantire che questo test funzioni, siamo a posto.
Testare gli hook senza una libreria
In questa sezione vedremo come testare gli hook senza utilizzare alcuna libreria. Questo ci fornirà una comprensione approfondita di come testare React Hooks.
Per iniziare questo test, in primo luogo, vorremmo simulare fetch
. In questo modo possiamo avere il controllo su ciò che l'API restituisce. Ecco il fetch
deriso.
function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }
Questo fetch
modificato presuppone che il tipo di risposta sia sempre JSON e, per impostazione predefinita, restituisce l' url
del parametro come valore dei data
. Aggiunge anche un ritardo casuale compreso tra 200 ms e 500 ms alla risposta.
Se vogliamo modificare la risposta, impostiamo semplicemente il suffix
del secondo argomento su un valore di stringa non vuoto.
A questo punto, potresti chiederti, perché il ritardo? Perché non restituiamo la risposta all'istante? Questo perché vogliamo replicare il più possibile il mondo reale. Non possiamo testare correttamente il gancio se lo restituiamo immediatamente. Certo, possiamo ridurre il ritardo a 50-100 ms per test più veloci, ma non preoccupiamocene in questo articolo.
Con il nostro fetch mock pronto, possiamo impostarlo sulla funzione di fetch
. Usiamo beforeAll
e afterAll
per farlo perché questa funzione è senza stato, quindi non è necessario ripristinarla dopo un test individuale.
// runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });
Quindi, dobbiamo montare il gancio in un componente. Come mai? Perché gli hook sono solo funzioni a sé stanti. Solo se utilizzati nei componenti possono rispondere a useState
, useEffect
, ecc.
Quindi, dobbiamo creare un TestComponent
che ci aiuti a montare il nostro hook.
// 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>; }
Questo è un componente semplice che esegue il rendering dei dati o esegue il rendering di un messaggio di testo "Caricamento" se i dati vengono caricati (vengono recuperati).
Una volta che abbiamo il componente di test, dobbiamo montarlo sul DOM. Usiamo beforeEach
e afterEach
per montare e smontare il nostro componente per ogni test perché vogliamo iniziare con un nuovo DOM prima di ogni 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; });
Si noti che container
deve essere una variabile globale poiché vogliamo accedervi per le asserzioni di test.
Con quel set, eseguiamo il nostro primo test in cui eseguiamo il rendering di un URL url1
e poiché il recupero dell'URL richiederà del tempo (vedi fetchMock
), inizialmente dovrebbe eseguire il rendering del testo di "caricamento".
it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })
Esegui il test usando il test del yarn test
e funziona come previsto. Ecco il codice completo su GitHub.
Ora, testiamo quando questo testo di loading
cambia nei dati di risposta recuperati, url1
.
Come lo facciamo? Se guardi fetchMock
, vedi che aspettiamo 200-500 millisecondi. E se inserissimo un sleep
nel test che attende 500 millisecondi? Coprirà tutti i possibili tempi di attesa. Proviamo quello.
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"); });
Il test passa, ma vediamo anche un errore (codice).
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(...).
Questo perché l'aggiornamento dello stato useStaleRefresh
avviene al di fuori di act(). Per assicurarsi che gli aggiornamenti DOM vengano elaborati tempestivamente, React consiglia di utilizzare act()
ogni volta che potrebbe verificarsi un nuovo rendering o un aggiornamento dell'interfaccia utente. Quindi, dobbiamo concludere il nostro sonno con l' act
poiché questo è il momento in cui avviene l'aggiornamento dello stato. Dopo averlo fatto, l'errore scompare.
import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));
Ora, eseguilo di nuovo (codice su GitHub). Come previsto, passa senza errori.
Proviamo la prossima situazione in cui prima cambiamo l'URL in url2
, quindi controlliamo la schermata di loading
, quindi attendiamo la risposta di recupero e infine controlliamo il testo url2
. Poiché ora sappiamo come attendere correttamente le modifiche asincrone, dovrebbe essere facile.

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
Esegui questo test e anche lui passa. Ora possiamo anche testare il caso in cui i dati di risposta cambiano e la cache entra in gioco.
Noterai che abbiamo un suffix
di argomento aggiuntivo nella nostra funzione fetchMock . Questo serve per modificare i dati di risposta. Quindi aggiorniamo il nostro fetch mock per usare il suffix
.
global.fetch.mockImplementation((url) => fetchMock(url, "__"));
Ora possiamo testare il caso in cui l'URL è impostato di nuovo su url1
. Prima carica url1
e poi url1__
. Possiamo fare lo stesso per url2
e non dovrebbero esserci sorprese.
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'intero test ci dà la certezza che l'hook funziona davvero come previsto (codice). Evviva! Ora, diamo una rapida occhiata all'ottimizzazione utilizzando i metodi di supporto.
Ottimizzazione dei test utilizzando i metodi di supporto
Finora, abbiamo visto come testare completamente il nostro gancio. L'approccio non è perfetto ma funziona. Eppure, possiamo fare di meglio?
Sì. Si noti che stiamo aspettando 500 ms fissi per il completamento di ogni recupero, ma ogni richiesta richiede da 200 a 500 ms. Quindi, stiamo chiaramente perdendo tempo qui. Possiamo gestirlo meglio semplicemente aspettando il tempo impiegato da ciascuna richiesta.
Come lo facciamo? Una tecnica semplice consiste nell'eseguire l'asserzione finché non passa o viene raggiunto un timeout. Creiamo una funzione waitFor
che lo faccia.
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"); } }
Questa funzione esegue semplicemente un callback (cb) all'interno di un blocco try...catch
ogni 10 ms e, se viene raggiunto il timeout
, genera un errore. Questo ci permette di eseguire un'asserzione finché non passa in modo sicuro (cioè senza cicli infiniti).
Possiamo usarlo nel nostro test come segue: invece di dormire per 500 ms e poi affermare, utilizziamo la nostra funzione waitFor
.
// INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );
Fallo in tutte queste affermazioni e possiamo vedere una notevole differenza nella velocità di esecuzione del nostro test (codice).
Ora, tutto questo è fantastico, ma forse non vogliamo testare l'hook tramite l'interfaccia utente. Forse vogliamo testare un hook usando i suoi valori di ritorno. Come lo facciamo?
Non sarà difficile perché abbiamo già accesso ai valori di ritorno del nostro hook. Sono solo all'interno del componente. Se riusciamo a portare queste variabili nell'ambito globale, funzionerà. Quindi facciamolo.
Poiché testeremo il nostro hook tramite il suo valore restituito e non renderizzato DOM, possiamo rimuovere il rendering HTML dal nostro componente e renderlo null
. Dovremmo anche rimuovere la destrutturazione nel ritorno di hook per renderlo più generico. Pertanto, abbiamo questo componente di test aggiornato.
// global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }
Ora il valore restituito dall'hook è memorizzato in result
, una variabile globale. Possiamo interrogarlo per le nostre asserzioni.
// 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");
Dopo averlo cambiato ovunque, possiamo vedere che i nostri test stanno passando (codice).
A questo punto, otteniamo l'essenza del test di React Hooks. Ci sono alcuni miglioramenti che possiamo ancora apportare, come ad esempio:
- Spostamento della variabile di
result
in un ambito locale - Eliminando la necessità di creare un componente per ogni hook che vogliamo testare
Possiamo farlo creando una funzione di fabbrica che ha un componente di test al suo interno. Dovrebbe anche rendere l'hook nel componente di test e darci accesso alla variabile di result
. Vediamo come possiamo farlo.
Innanzitutto, spostiamo TestComponent
e result
all'interno della funzione. Dovremo anche passare Hook e gli argomenti Hook come argomenti della funzione in modo che possano essere usati nel nostro componente di test. Usandolo, ecco cosa abbiamo. Chiamiamo questa funzione renderHook
.
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }
Il motivo per cui abbiamo result
come oggetto che memorizza i dati in result.current
è perché vogliamo che i valori restituiti vengano aggiornati durante l'esecuzione del test. Il valore di ritorno del nostro hook è un array, quindi sarebbe stato copiato per valore se lo avessimo restituito direttamente. Memorizzalo in un oggetto, restituiamo un riferimento a quell'oggetto in modo che i valori restituiti possano essere aggiornati aggiornando result.current
.
Ora, come possiamo aggiornare l'hook? Dato che stiamo già usando una chiusura, racchiudiamo un'altra funzione di rerender
in grado di farlo.
La funzione finale renderHook
si presenta così:
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 }; }
Ora possiamo usarlo nel nostro test. Invece di usare act
and render
, facciamo quanto segue:
const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);
Quindi, possiamo affermare usando result.current
e aggiornare l'hook usando rerender
. Ecco un semplice esempio:
rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true
Dopo averlo modificato in tutti i punti, vedrai che funziona senza problemi (codice).
Brillante! Ora abbiamo un'astrazione molto più pulita per testare gli hook. Possiamo ancora fare di meglio, ad esempio, defaultValue
deve essere passato ogni volta per eseguire il rerender
anche se non cambia. Noi possiamo aggiustarlo.
Ma non giriamo troppo intorno al cespuglio perché abbiamo già una libreria che migliora notevolmente questa esperienza.
Entra nella libreria di test-hooks-react.
Test utilizzando React-hooks-testing-library
React-hooks-testing-library fa tutto ciò di cui abbiamo parlato prima e poi alcuni. Ad esempio, gestisce il montaggio e lo smontaggio del contenitore in modo da non doverlo fare nel file di test. Questo ci consente di concentrarci sul test dei nostri ganci senza distrarci.
Viene fornito con una funzione renderHook
che restituisce rerender
e il result
. Restituisce anche wait
, che è simile a waitFor
, quindi non è necessario implementarlo da soli.
Ecco come renderizziamo un hook in React-hooks-testing-library. Si noti che l'hook viene passato sotto forma di callback. Questo callback viene eseguito ogni volta che il componente di test esegue nuovamente il rendering.
const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );
Quindi, possiamo verificare se il primo rendering ha comportato isLoading
come true e restituire il valore come defaultValue
in questo modo. Esattamente simile a quello che abbiamo implementato sopra.
expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);
Per testare gli aggiornamenti asincroni, possiamo usare il metodo wait
restituito da renderHook
. Viene avvolto con act()
quindi non è necessario avvolgere act()
attorno ad esso.
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
Quindi, possiamo usare rerender
per aggiornarlo con nuovi oggetti di scena. Si noti che non è necessario passare defaultValue
qui.
rerender({ url: "url2" });
Infine, il resto del test procederà in modo simile (codice).
Avvolgendo
Il mio obiettivo era mostrarti come testare React Hooks prendendo un esempio di hook asincrono. Spero che questo ti aiuti ad affrontare con sicurezza il test di qualsiasi tipo di gancio, poiché lo stesso approccio dovrebbe applicarsi alla maggior parte di essi.
Ti consiglierei di utilizzare React-hooks-testing-library poiché è completo e finora non ho riscontrato problemi significativi con esso. Nel caso in cui riscontri un problema, ora sai come affrontarlo utilizzando la complessità dei test hook descritti in questo articolo.