Kompletny przewodnik po testowaniu haków React
Opublikowany: 2022-03-11Hooki zostały wprowadzone w React 16.8 pod koniec 2018 roku. Są to funkcje, które łączą się z komponentami funkcjonalnymi i pozwalają nam używać funkcji stanu i komponentów, takich jak componentDidUpdate
, componentDidMount
i innych. Wcześniej nie było to możliwe.
Ponadto haki umożliwiają nam ponowne wykorzystanie logiki komponentów i stanów w różnych komponentach. Wcześniej było to trudne. Dlatego haki zmieniły zasady gry.
W tym artykule dowiemy się, jak testować hooki reakcji. Wybierzemy wystarczająco złożony hak i będziemy pracować nad jego testowaniem.
Spodziewamy się, że jesteś zapalonym programistą React, który już zna React Hooki. Jeśli chcesz odświeżyć swoją wiedzę, zapoznaj się z naszym samouczkiem, a tutaj znajduje się link do oficjalnej dokumentacji.
Hak, którego użyjemy do testów
W tym artykule użyjemy haka, który napisałem w poprzednim artykule, Pobieranie danych z przestarzałych warunków podczas ponownego sprawdzania poprawności za pomocą haków reakcji. Hak nazywa się useStaleRefresh
. Jeśli nie przeczytałeś tego artykułu, nie martw się, ponieważ podsumuję tę część tutaj.
Oto hak, który będziemy testować:
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]; }
Jak widać, useStaleRefresh
to hak, który pomaga pobierać dane z adresu URL, jednocześnie zwracając wersję danych w pamięci podręcznej, jeśli taka istnieje. Używa prostego magazynu w pamięci do przechowywania pamięci podręcznej.
Zwraca również wartość isLoading
, która jest prawdziwa, jeśli dane lub pamięć podręczna nie są jeszcze dostępne. Klient może go użyć do pokazania wskaźnika ładowania. Wartość isLoading
jest ustawiona na false, gdy dostępna jest pamięć podręczna lub nowa odpowiedź.
W tym momencie sugeruję, abyś poświęcił trochę czasu na przeczytanie powyższego haka, aby w pełni zrozumieć, co robi.
W tym artykule zobaczymy, jak możemy przetestować ten hook, najpierw przy użyciu bibliotek no test (tylko React Test Utilities i Jest), a następnie przy użyciu biblioteki retrieve-hooks-testing-library.
Motywacją do używania bibliotek no test, tj. tylko programu uruchamiającego testy Jest
, jest zademonstrowanie, jak działa testowanie podpięcia. Dzięki tej wiedzy będziesz w stanie debugować wszelkie problemy, które mogą się pojawić podczas korzystania z biblioteki, która zapewnia abstrakcję testową.
Definiowanie przypadków testowych
Zanim zaczniemy testować ten hak, wymyślmy plan tego, co chcemy przetestować. Ponieważ wiemy, co ma robić hak, oto mój ośmiostopniowy plan jego testowania:
- Kiedy podpięcie jest zamontowane z URL
url1
,isLoading
ma wartośćtrue
a data todefaultValue
. - Po asynchronicznym żądaniu pobrania przechwycenie jest aktualizowane danymi
data1
iisLoading
ma wartośćfalse
. - Kiedy adres URL zostanie zmieniony na
url2
,isLoading
ponownie staje się prawdą , a dane majądefaultValue
. - Po asynchronicznym żądaniu pobrania hak jest aktualizowany nowymi danymi
data2
. - Następnie zmieniamy adres URL z powrotem na
url1
. Danedata1
są natychmiast odbierane, ponieważ są buforowane.isLoading
jest fałszywe. - Po asynchronicznym żądaniu pobrania, po otrzymaniu nowej odpowiedzi, dane są aktualizowane do
data3
. - Następnie zmieniamy adres URL z powrotem na
url2
. Danedata2
są natychmiast odbierane, ponieważ są buforowane.isLoading
jest fałszywe. - Po asynchronicznym żądaniu pobrania, po otrzymaniu nowej odpowiedzi, dane są aktualizowane do
data4
.
Wspomniany powyżej przebieg testu jasno określa trajektorię działania haka. Dlatego jeśli możemy zapewnić, że ten test zadziała, jesteśmy dobrzy.
Testowanie hooków bez biblioteki
W tej sekcji zobaczymy, jak testować hooki bez użycia bibliotek. To zapewni nam dogłębne zrozumienie, jak testować hooki reakcji.
Aby rozpocząć ten test, najpierw chcielibyśmy fetch
. Dzięki temu możemy mieć kontrolę nad tym, co zwraca API. Oto wyśmiewany fetch
.
function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }
To zmodyfikowane fetch
zakłada, że typem odpowiedzi jest zawsze JSON i domyślnie zwraca parametr url
jako wartość data
. Dodaje również losowe opóźnienie od 200 ms do 500 ms do odpowiedzi.
Jeśli chcemy zmienić odpowiedź, po prostu ustawiamy suffix
drugiego argumentu na niepustą wartość ciągu.
W tym momencie możesz zapytać, dlaczego opóźnienie? Dlaczego po prostu nie zwrócimy odpowiedzi natychmiast? Dzieje się tak dlatego, że chcemy jak najbardziej odwzorowywać rzeczywisty świat. Nie możemy poprawnie przetestować hooka, jeśli zwrócimy go natychmiast. Jasne, możemy zmniejszyć opóźnienie do 50-100 ms dla szybszych testów, ale nie przejmujmy się tym w tym artykule.
Mając gotowy model pobierania, możemy ustawić go na funkcję fetch
. W tym celu używamy beforeAll
i afterAll
, ponieważ ta funkcja jest bezstanowa, więc nie musimy jej resetować po indywidualnym teście.
// runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });
Następnie musimy zamontować haczyk w komponencie. Czemu? Ponieważ haki to tylko funkcje same w sobie. Tylko użyte w komponentach mogą reagować na useState
, useEffect
itp.
Musimy więc stworzyć TestComponent
, który pomoże nam zamontować nasz hak.
// 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>; }
Jest to prosty komponent, który renderuje dane lub wyświetla monit tekstowy „Ładowanie” , jeśli dane są ładowane (pobierane).
Gdy mamy już komponent testowy, musimy go zamontować na DOM. Używamy beforeEach
i afterEach
do montowania i odmontowywania naszego komponentu dla każdego testu, ponieważ chcemy zacząć od świeżego DOM przed każdym testem.
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; });
Zauważ, że container
musi być zmienną globalną, ponieważ chcemy mieć do niej dostęp do asercji testowych.
Mając ten zestaw, zróbmy nasz pierwszy test, w którym renderujemy URL url1
, a ponieważ pobranie adresu URL zajmie trochę czasu (zobacz fetchMock
), powinno początkowo renderować tekst „ładujący”.
it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })
Uruchom test za pomocą testu yarn test
i działa zgodnie z oczekiwaniami. Oto pełny kod na GitHub.
Przetestujmy teraz, kiedy ten loading
się tekst zmieni się w pobrane dane odpowiedzi, url1
.
Jak to zrobimy? Jeśli spojrzysz na fetchMock
, zobaczysz, że czekamy 200-500 milisekund. Co jeśli w teście sleep
, który poczeka 500 milisekund? Obejmuje wszystkie możliwe czasy oczekiwania. Spróbujmy tego.
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"); });
Test przeszedł pomyślnie, ale widzimy też błąd (kod).
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(...).
Dzieje się tak, ponieważ aktualizacja stanu w useStaleRefresh
odbywa się poza act(). Aby mieć pewność, że aktualizacje DOM są przetwarzane na czas, React zaleca używanie act()
za każdym razem, gdy może nastąpić ponowne renderowanie lub aktualizacja interfejsu użytkownika. Tak więc musimy spać z act
ponieważ jest to czas, w którym następuje aktualizacja stanu. Po wykonaniu tej czynności błąd znika.
import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));
Teraz uruchom go ponownie (kod na GitHub). Zgodnie z oczekiwaniami mija bez błędów.
Przetestujmy następną sytuację, w której najpierw zmieniamy adres URL na url2
, następnie sprawdzamy ekran loading
, potem czekamy na odpowiedź pobierania, a na końcu sprawdzamy tekst url2
. Ponieważ teraz wiemy, jak poprawnie czekać na zmiany asynchroniczne, powinno to być łatwe.

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
Uruchom ten test, a on również przechodzi. Teraz możemy również przetestować przypadek, w którym zmieniają się dane odpowiedzi i w grę wchodzi pamięć podręczna.
Zauważysz, że w naszej funkcji fetchMock mamy dodatkowy suffix
argumentu. Służy do zmiany danych odpowiedzi. Dlatego aktualizujemy nasz model pobierania, aby używał suffix
.
global.fetch.mockImplementation((url) => fetchMock(url, "__"));
Teraz możemy ponownie przetestować przypadek, w którym adres URL jest ustawiony na url1
. Najpierw ładuje url1
, a następnie url1__
. Możemy zrobić to samo dla url2
i nie powinno być żadnych niespodzianek.
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__"); });
Cały ten test daje nam pewność, że hak rzeczywiście działa zgodnie z oczekiwaniami (kod). Hurra! Przyjrzyjmy się teraz optymalizacji przy użyciu metod pomocniczych.
Optymalizacja testowania przy użyciu metod pomocniczych
Do tej pory widzieliśmy, jak całkowicie przetestować nasz hak. Podejście nie jest idealne, ale działa. A jednak czy możemy zrobić lepiej?
TAk. Zauważ, że czekamy na stałe 500 ms na zakończenie każdego pobierania, ale każde żądanie zajmuje od 200 do 500 ms. Tak więc wyraźnie tracimy tutaj czas. Możemy sobie z tym lepiej poradzić, po prostu czekając na czas potrzebny na każde żądanie.
Jak to zrobimy? Prostą techniką jest wykonywanie asercji do momentu jej przejścia lub przekroczenia limitu czasu. Stwórzmy funkcję waitFor
, która to zrobi.
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"); } }
Ta funkcja po prostu uruchamia wywołanie zwrotne (cb) wewnątrz bloku try...catch
co 10 ms, a po osiągnięciu timeout
zgłasza błąd. To pozwala nam uruchamiać asercję, dopóki nie przejdzie w bezpieczny sposób (tj. bez nieskończonych pętli).
Możemy go użyć w naszym teście w następujący sposób: Zamiast spać przez 500 ms, a następnie potwierdzić, używamy naszej funkcji waitFor
.
// INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );
Zrób to we wszystkich takich asercjach, a zauważymy znaczną różnicę w szybkości działania naszego testu (kodu).
Wszystko to jest świetne, ale może nie chcemy testować podpięcia za pomocą interfejsu użytkownika. Może chcemy przetestować podpięcie przy użyciu jego zwracanych wartości. Jak to zrobimy?
Nie będzie to trudne, ponieważ mamy już dostęp do wartości zwracanych przez nasz hook. Są po prostu wewnątrz komponentu. Jeśli możemy przenieść te zmienne do zakresu globalnego, to zadziała. Więc zróbmy to.
Ponieważ będziemy testować nasz hook za pomocą jego wartości zwracanej, a nie renderowanego DOM, możemy usunąć render HTML z naszego komponentu i uczynić go render null
. Powinniśmy również usunąć destrukturyzację w powrocie hooka, aby uczynić go bardziej ogólnym. W ten sposób mamy ten zaktualizowany komponent testowy.
// global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }
Teraz wartość zwracana przez hook jest przechowywana w zmiennej globalnej result
. Możemy zapytać go o nasze twierdzenia.
// 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");
Gdy zmienimy go wszędzie, widzimy, że nasze testy kończą się pomyślnie (kod).
W tym momencie otrzymujemy sedno testowania haków reakcyjnych. Wciąż możemy wprowadzić kilka ulepszeń, takich jak:
- Przenoszenie zmiennej
result
do zakresu lokalnego - Usunięcie potrzeby tworzenia komponentu dla każdego haka, który chcemy przetestować
Możemy to zrobić, tworząc funkcję fabryczną, w której znajduje się komponent testowy. Powinno to również wyrenderować hook w komponencie test i dać nam dostęp do zmiennej result
. Zobaczmy, jak możemy to zrobić.
Najpierw przenosimy TestComponent
i result
wewnątrz funkcji. Będziemy również musieli przekazać argumenty hooka i hooka jako argumenty funkcji, aby można je było wykorzystać w naszym komponencie testowym. Korzystając z tego, oto co mamy. Nazywamy tę funkcję renderHook
.
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }
Powodem, dla którego otrzymaliśmy result
jako obiekt, który przechowuje dane w result.current
jest to, że chcemy, aby wartości zwracane były aktualizowane podczas wykonywania testu. Zwracaną wartością naszego hooka jest tablica, więc zostałaby skopiowana przez wartość, gdybyśmy zwrócili ją bezpośrednio. Przechowując go w obiekcie, zwracamy referencję do tego obiektu, dzięki czemu zwracane wartości mogą być aktualizowane poprzez aktualizację result.current
.
Jak mamy zaktualizować haka? Ponieważ już używamy domknięcia, dołączmy inną funkcję rerender
, która może to zrobić.
Ostateczna funkcja renderHook
wygląda tak:
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 }; }
Teraz możemy go użyć w naszym teście. Zamiast używać act
i render
, wykonujemy następujące czynności:
const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);
Następnie możemy potwierdzić za pomocą result.current
i zaktualizować podpięcie za pomocą rerender
. Oto prosty przykład:
rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true
Gdy zmienisz go we wszystkich miejscach, zobaczysz, że działa bez żadnych problemów (kod).
Znakomity! Teraz mamy znacznie czystszą abstrakcję do testowania hooków. Nadal możemy zrobić lepiej — na przykład defaultValue
musi być przekazywana za każdym razem, aby rerender
, nawet jeśli się nie zmienia. Możemy to naprawić.
Ale nie owijmy się zbytnio w bawełnę, ponieważ mamy już bibliotekę, która znacznie poprawia to doświadczenie.
Wejdź w bibliotekę-testującą-reaguj-hooks-library.
Testowanie przy użyciu biblioteki React-hooks-testing-library
Biblioteka-testów-reagujących-testów robi wszystko, o czym mówiliśmy wcześniej, a nawet więcej. Na przykład obsługuje montowanie i odmontowywanie kontenerów, więc nie musisz tego robić w pliku testowym. To pozwala nam skupić się na testowaniu naszych haczyków bez rozpraszania się.
Jest wyposażony w funkcję renderHook
, która zwraca rerender
i result
. Zwraca również wait
, które jest podobne do waitFor
, więc nie musisz go samodzielnie implementować.
Oto jak renderujemy hook w bibliotece React-hooks-testing-library. Zauważ, że hak jest przekazywany w formie wywołania zwrotnego. To wywołanie zwrotne jest uruchamiane za każdym razem, gdy komponent testowy jest ponownie renderowany.
const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );
Następnie możemy sprawdzić, czy pierwsze renderowanie spowodowało, że isLoading
wartość true i zwrócić wartość jako defaultValue
, wykonując to. Dokładnie podobny do tego, co wdrożyliśmy powyżej.
expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);
Aby przetestować aktualizacje asynchroniczne, możemy użyć metody wait
zwracanej przez renderHook
. Jest zapakowany w act()
act()
więc nie musimy go otaczać.
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
Następnie możemy użyć rerender
, aby zaktualizować go o nowe rekwizyty. Zauważ, że nie musimy tutaj przekazywać defaultValue
.
rerender({ url: "url2" });
Wreszcie reszta testu będzie przebiegać podobnie (kod).
Zawijanie
Moim celem było pokazanie, jak testować hooki reakcji na przykładzie haka asynchronicznego. Mam nadzieję, że pomoże ci to pewnie poradzić sobie z testowaniem wszelkiego rodzaju haków, ponieważ to samo podejście powinno dotyczyć większości z nich.
Sugerowałbym korzystanie z biblioteki React-hooks-testing-library, ponieważ jest ona kompletna i do tej pory nie miałem z nią większych problemów. Jeśli napotkasz problem, wiesz już, jak do niego podejść, korzystając z zawiłości testowych hooków opisanych w tym artykule.