Un ghid complet pentru testarea cârligelor React
Publicat: 2022-03-11Cârligele au fost introduse în React 16.8 la sfârșitul anului 2018. Sunt funcții care se conectează la o componentă funcțională și ne permit să folosim funcții de stare și componente precum componentDidUpdate
, componentDidMount
și multe altele. Acest lucru nu era posibil înainte.
De asemenea, cârligele ne permit să reutilizam logica componentelor și a stării pe diferite componente. Înainte era dificil de făcut. Prin urmare, cârligele au schimbat jocul.
În acest articol, vom explora cum să testăm React Hooks. Vom alege un cârlig suficient de complex și vom lucra la testarea lui.
Ne așteptăm să fiți un dezvoltator pasionat React deja familiarizat cu React Hooks. În cazul în care doriți să vă îmbunătățiți cunoștințele, ar trebui să consultați tutorialul nostru și aici este linkul către documentația oficială.
Cârligul pe care îl vom folosi pentru testare
Pentru acest articol, vom folosi un cârlig pe care l-am scris în articolul meu anterior, Stale-while-revalidate Data Fetching with React Hooks. Cârligul se numește useStaleRefresh
. Dacă nu ați citit articolul, nu vă faceți griji deoarece voi recapitula acea parte aici.
Acesta este cârligul pe care îl vom testa:
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]; }
După cum puteți vedea, useStaleRefresh
este un cârlig care ajută la preluarea datelor de la o adresă URL în timp ce returnează o versiune stocată în cache a datelor, dacă aceasta există. Folosește un simplu magazin în memorie pentru a păstra memoria cache.
De asemenea, returnează o valoare isLoading
care este adevărată dacă nu sunt încă disponibile date sau cache. Clientul îl poate folosi pentru a afișa un indicator de încărcare. Valoarea isLoading
este setată la false atunci când este disponibil cache sau un răspuns proaspăt.
În acest moment, vă sugerez să petreceți ceva timp citind cârligul de mai sus pentru a înțelege complet ceea ce face.
În acest articol, vom vedea cum putem testa acest cârlig, mai întâi folosind biblioteci fără testare (doar React Test Utilities și Jest) și apoi folosind react-hooks-testing-library.
Motivația din spatele utilizării fără biblioteci de testare, adică doar a unui test Runner Jest
, este de a demonstra cum funcționează testarea unui cârlig. Cu aceste cunoștințe, veți putea depana orice probleme care pot apărea atunci când utilizați o bibliotecă care oferă abstractizare de testare.
Definirea cazurilor de testare
Înainte de a începe să testăm acest cârlig, să venim cu un plan cu ceea ce vrem să testăm. Din moment ce știm ce ar trebui să facă cârligul, iată planul meu în opt pași pentru testarea lui:
- Când cârligul este montat cu URL-ul
url1
,isLoading
estetrue
și data estedefaultValue
. - După o solicitare de preluare asincronă, cârligul este actualizat cu date
data1
șiisLoading
estefalse
. - Când URL-ul este schimbat în
url2
,isLoading
devine din nou adevărat și datele suntdefaultValue
. - După o solicitare de preluare asincronă, cârligul este actualizat cu date noi de
data2
. - Apoi, schimbăm URL-ul înapoi la
url1
.data1
sunt primite instantaneu, deoarece sunt stocate în cache.isLoading
este fals. - După o solicitare de preluare asincronă, atunci când se primește un răspuns nou, datele sunt actualizate la
data3
. - Apoi, schimbăm URL-ul înapoi la
url2
.data2
sunt primite instantaneu, deoarece sunt stocate în cache.isLoading
este fals. - După o solicitare de preluare asincronă, atunci când se primește un răspuns nou, datele sunt actualizate la
data4
.
Fluxul de testare menționat mai sus definește clar traiectoria modului în care va funcționa cârligul. Prin urmare, dacă ne putem asigura că acest test funcționează, suntem buni.
Testarea cârligelor fără bibliotecă
În această secțiune, vom vedea cum să testăm cârlige fără a folosi biblioteci. Acest lucru ne va oferi o înțelegere aprofundată a modului de testare a React Hooks.
Pentru a începe acest test, mai întâi, am dori să facem joc de fetch
. Acest lucru este astfel încât să putem avea control asupra a ceea ce returnează API-ul. Aici este fetch
batjocorită.
function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }
Această fetch
modificată presupune că tipul de răspuns este întotdeauna JSON și, implicit, returnează url
-ul parametrului ca valoare a data
. De asemenea, adaugă o întârziere aleatorie între 200 ms și 500 ms răspunsului.
Dacă vrem să schimbăm răspunsul, pur și simplu setăm suffix
al doilea argument la o valoare șir nevid.
În acest moment, s-ar putea să vă întrebați, de ce întârzierea? De ce nu returnăm răspunsul instantaneu? Acest lucru se datorează faptului că vrem să reproducem lumea reală cât mai mult posibil. Nu putem testa corect cârligul dacă îl returnăm instantaneu. Sigur, putem reduce întârzierea la 50-100 ms pentru teste mai rapide, dar să nu ne facem griji pentru asta în acest articol.
Cu modelul nostru de preluare gata, îl putem seta la funcția de fetch
. Folosim beforeAll
și afterAll
pentru a face acest lucru, deoarece această funcție este apatridă, așa că nu trebuie să o resetam după un test individual.
// runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });
Apoi, trebuie să montăm cârligul într-o componentă. De ce? Pentru că cârligele sunt doar funcții în sine. Numai atunci când sunt utilizate în componente pot răspunde la useState
, useEffect
etc.
Deci, trebuie să creăm un TestComponent
care ne ajută să ne montăm cârligul.
// 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>; }
Aceasta este o componentă simplă care fie redă datele, fie redă un mesaj text „Încărcare” dacă datele se încarcă (în curs de preluare).
Odată ce avem componenta de testare, trebuie să o montam pe DOM. Folosim beforeEach
și afterEach
pentru a monta și demonta componenta noastră pentru fiecare test, deoarece dorim să începem cu un DOM nou înainte de fiecare 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; });
Observați că container
trebuie să fie o variabilă globală, deoarece dorim să avem acces la el pentru aserțiuni de testare.
Cu acest set, să facem primul nostru test în care redăm o adresă URL url1
și, deoarece preluarea adresei URL va dura ceva timp (consultați fetchMock
), ar trebui să redăm inițial textul de „încărcare”.
it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })
Rulați testul folosind testul yarn test
și funcționează așa cum era de așteptat. Iată codul complet pe GitHub.
Acum, să testăm când acest text de loading
se modifică în datele de răspuns preluate, url1
.
Cum facem asta? Dacă te uiți la fetchMock
, vezi că așteptăm 200-500 de milisecunde. Ce se întâmplă dacă punem un sleep
în test care așteaptă 500 de milisecunde? Acesta va acoperi toți timpul posibil de așteptare. Să încercăm asta.
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"); });
Testul trece, dar vedem și o eroare (cod).
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(...).
Acest lucru se datorează faptului că actualizarea stării în hook useStaleRefresh
are loc în afara act(). Pentru a vă asigura că actualizările DOM sunt procesate în timp util, React vă recomandă să utilizați act()
de fiecare dată când s-ar întâmpla o re-rendare sau o actualizare a UI. Deci, trebuie să ne încheiem somnul cu act
, deoarece acesta este momentul în care are loc actualizarea stării. După ce faceți acest lucru, eroarea dispare.
import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));
Acum, rulați-l din nou (cod pe GitHub). După cum era de așteptat, trece fără erori.
Să testăm următoarea situație în care mai întâi schimbăm adresa URL în url2
, apoi verificăm ecranul de loading
, apoi așteptăm răspunsul de preluare și, în sfârșit, verificăm textul url2
. Deoarece acum știm cum să așteptăm corect modificările asincrone, acest lucru ar trebui să fie ușor.

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
Rulați acest test și trece și el. Acum, putem testa și cazul în care datele de răspuns se modifică și cache-ul intră în joc.
Veți observa că avem un suffix
de argument suplimentar în funcția noastră fetchMock . Aceasta este pentru modificarea datelor de răspuns. Așa că ne actualizăm mock-ul de preluare pentru a folosi suffix
.
global.fetch.mockImplementation((url) => fetchMock(url, "__"));
Acum, putem testa din nou cazul în care URL-ul este setat la url1
. Mai întâi încarcă url1
și apoi url1__
. Putem face același lucru pentru url2
și nu ar trebui să existe surprize.
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__"); });
Întregul test ne oferă încrederea că cârligul funcționează într-adevăr conform așteptărilor (cod). Ura! Acum, să aruncăm o privire rapidă asupra optimizării folosind metode de ajutor.
Optimizarea testării prin utilizarea metodelor de ajutor
Până acum, am văzut cum să ne testăm complet cârligul. Abordarea nu este perfectă, dar funcționează. Și totuși, putem face mai bine?
Da. Observați că așteptăm o durată fixă de 500 ms pentru fiecare preluare care urmează să fie finalizată, dar fiecare solicitare durează de la 200 la 500 ms. Deci, este clar că pierdem timpul aici. Ne putem descurca mai bine cu asta doar așteptând timpul necesar pentru fiecare solicitare.
Cum facem asta? O tehnică simplă este executarea aserției până când trece sau se atinge un timeout. Să creăm o funcție waitFor
care face asta.
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"); } }
Această funcție pur și simplu rulează un callback (cb) în interiorul unui bloc try...catch
la fiecare 10 ms, iar dacă este atins timpul de timeout
, aruncă o eroare. Acest lucru ne permite să rulăm o aserțiune până când trece într-o manieră sigură (adică, fără bucle infinite).
Îl putem folosi în testul nostru după cum urmează: În loc să dormim timp de 500 ms și apoi să afirmăm, folosim funcția waitFor
.
// INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );
Faceți-o în toate astfel de afirmații și putem vedea o diferență considerabilă în ceea ce privește viteza cu care testul nostru rulează (codul).
Acum, toate acestea sunt grozave, dar poate că nu vrem să testăm cârligul prin UI. Poate dorim să testăm un cârlig folosind valorile sale returnate. Cum facem asta?
Nu va fi dificil pentru că avem deja acces la valorile de returnare ale cârligului nostru. Sunt doar în interiorul componentei. Dacă putem duce acele variabile în domeniul global, va funcționa. Deci hai să facem asta.
Deoarece ne vom testa cârligul prin valoarea de returnare și nu prin DOM redat, putem elimina randarea HTML din componenta noastră și o putem face să redea null
. De asemenea, ar trebui să eliminăm destructurarea în hook's return pentru a o face mai generică. Astfel, avem această componentă de testare actualizată.
// global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }
Acum valoarea returnată a cârligului este stocată în result
, o variabilă globală. Îl putem interoga pentru afirmațiile noastre.
// 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");
După ce îl schimbăm peste tot, putem vedea că testele noastre trec (cod).
În acest moment, obținem esența testării React Hooks. Există câteva îmbunătățiri pe care încă le putem aduce, cum ar fi:
- Mutarea variabilei
result
într-un domeniu local - Eliminarea necesității de a crea o componentă pentru fiecare cârlig pe care vrem să-l testăm
O putem face prin crearea unei funcții din fabrică care are o componentă de testare în interior. De asemenea, ar trebui să redeze cârligul în componenta de testare și să ne dea acces la variabila result
. Să vedem cum putem face asta.
Mai întâi, mutăm TestComponent
și result
în funcție. De asemenea, va trebui să transmitem argumentele Hook și Hook ca argumente ale funcției, astfel încât acestea să poată fi utilizate în componenta noastră de testare. Folosind asta, iată ce avem. Numim această funcție renderHook
.
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }
Motivul pentru care avem result
ca obiect care stochează date în result.current
este pentru că dorim ca valorile returnate să fie actualizate pe măsură ce testul rulează. Valoarea de returnare a cârligului nostru este o matrice, deci ar fi fost copiată după valoare dacă am returnat-o direct. Stocându-l într-un obiect, returnăm o referință la acel obiect, astfel încât valorile returnate să poată fi actualizate prin actualizarea result.current
.
Acum, cum facem să actualizăm cârligul? Deoarece folosim deja o închidere, să rerender
o altă redare a funcției care poate face asta.
Funcția finală renderHook
arată astfel:
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 }; }
Acum, îl putem folosi în testul nostru. În loc să folosim act
și render
, facem următoarele:
const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);
Apoi, putem afirma folosind result.current
și putem actualiza hook-ul folosind rerender
. Iată un exemplu simplu:
rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true
Odată ce îl schimbați în toate locurile, veți vedea că funcționează fără probleme (cod).
Sclipitor! Acum avem o abstractizare mult mai curată pentru a testa cârlige. Putem face totuși mai bine - de exemplu, defaultValue
trebuie să fie transmisă de fiecare dată pentru a rerender
, chiar dacă nu se schimbă. Putem repara asta.
Dar să nu ocolim prea mult tufișul, deoarece avem deja o bibliotecă care îmbunătățește această experiență în mod semnificativ.
Intrați în bibliotecă de testare-reacție-cârlige.
Testare folosind React-hooks-testing-library
React-hooks-testing-library face tot ce am vorbit înainte și apoi ceva. De exemplu, se ocupă de montarea și demontarea containerului, astfel încât să nu trebuie să faceți asta în fișierul de testare. Acest lucru ne permite să ne concentrăm pe testarea cârligelor noastre fără a ne distra.
Vine cu o funcție renderHook
care returnează rerender
și result
. De asemenea, returnează wait
, care este similar cu waitFor
, deci nu trebuie să îl implementați singur.
Iată cum redăm un hook în React-hooks-testing-library. Observați că cârligul este transmis sub forma unui apel invers. Acest apel invers este rulat de fiecare dată când componenta de testare este redată din nou.
const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );
Apoi, putem testa dacă prima randare a rezultat în isLoading
ca adevărat și valoarea returnată ca defaultValue
făcând acest lucru. Exact similar cu ceea ce am implementat mai sus.
expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);
Pentru a testa actualizările asincrone, putem folosi metoda wait
pe care a returnat- renderHook
. Vine împachetat cu act()
deci nu trebuie să încheim act()
în jurul lui.
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
Apoi, putem folosi rerender
pentru a o actualiza cu elemente de recuzită noi. Observați că nu trebuie să transmitem defaultValue
aici.
rerender({ url: "url2" });
În cele din urmă, restul testului va proceda în mod similar (cod).
Încheierea
Scopul meu a fost să vă arăt cum să testați React Hooks luând un exemplu de cârlig asincron. Sper că acest lucru vă va ajuta să abordați cu încredere testarea oricărui tip de cârlig, deoarece aceeași abordare ar trebui să se aplice pentru majoritatea acestora.
V-aș recomanda să utilizați React-hooks-testing-library deoarece este complet și nu am avut probleme semnificative cu ea până acum. În cazul în care întâmpinați o problemă, acum știți cum să o abordați folosind complexitatea cârligelor de testare descrise în acest articol.