Kompletny przewodnik po testowaniu haków React

Opublikowany: 2022-03-11

Hooki 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ź.

Schemat blokowy śledzący logikę nieaktualności podczas odświeżania

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:

  1. Kiedy podpięcie jest zamontowane z URL url1 , isLoading ma wartość true a data to defaultValue .
  2. Po asynchronicznym żądaniu pobrania przechwycenie jest aktualizowane danymi data1 i isLoading ma wartość false .
  3. Kiedy adres URL zostanie zmieniony na url2 , isLoading ponownie staje się prawdą , a dane mają defaultValue .
  4. Po asynchronicznym żądaniu pobrania hak jest aktualizowany nowymi danymi data2 .
  5. Następnie zmieniamy adres URL z powrotem na url1 . Dane data1 są natychmiast odbierane, ponieważ są buforowane. isLoading jest fałszywe.
  6. Po asynchronicznym żądaniu pobrania, po otrzymaniu nowej odpowiedzi, dane są aktualizowane do data3 .
  7. Następnie zmieniamy adres URL z powrotem na url2 . Dane data2 są natychmiast odbierane, ponieważ są buforowane. isLoading jest fałszywe.
  8. 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.

Przepływ testowy

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:

  1. Przenoszenie zmiennej result do zakresu lokalnego
  2. 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.