Jak pisać testy jednostkowe komponentów React z Jest i Enzyme

Jak to bywa z początkami, są trudne. Przypomniałem sobie o tym podczas pisania pierwszych testów jednostkowych w React. Dręczyło mnie wiele pytań: co powinienem testować? A co zostawić w spokoju? Czy moje testy zbytnio skupiają się na wewnętrznej mechanice komponentu? A może przez brak doświadczenia pomijam istotne elementy
interfejsu? Takie wątpliwości w połączeniu z rozbudowanym API Jest i Enzyme to idealna recepta na sparaliżowanie nawet najśmielszego adepta testowania. W tym artykule zaprezentuję metodę, która pozwoliła przełamać testerską blokadę i ruszyć z praktyką.

Wspomnianą metodę zaprezentował Stephen Scott w genialnym artykule The Right Way to Test React Components. Jest on stosunkowo długi i wymagający, ale warto poświecić pół godziny na jego uważną lekturę. Oczywiście nie musisz robić tego teraz :).

W tym wpisie przeprowadzę Cię przez wszystkie kluczowe elementy tej metody czyli proces definiowania kontraktu oraz pisanie testów na jego podstawie.

Czym jest kontrakt komponentu?

Kontrakt to nic innego jak nasze oczekiwania co do funkcjonalności komponentu i założenia dotyczące sposobu jego wykorzystywania.

Kontrakty i testy jednostkowe żyją ze sobą w swoistej równowadze. Kontrakt pomaga określić, co warto poddać testom jednostkowym. Za to testy jednostkowe pomagają zdefiniować kontrakt komponentu.

Na kontrakt każdego komponentu mają wpływ props, których komponent oczekuje, utrzymywany przez niego state, metody cyklu życia, obsługa zdarzeń etc.

Trzeba mieć również na uwadze, jak poszczególne wartości props czy state oddziaływują na komponent. Być może część jego kontraktu ulegnie zmianie po spełnieniu określonych warunków. Świetnym przykładem takiej sytuacji jest najprostrze wyświetlanie warunkowe w render().

Oczywiście to nie wszystko, w złożonych komponentach trzeba jeszcze wziąć pod uwagę kontekst (subskrypcje do redux store, routing), co wykracza poza zakres naszych dzisiejszych rozważań.

Kontrakt komponentu możesz łatwo zdefiniować poprzez udzielenie odpowiedzi na następujące pytania:

  1. Co renderuje komponent?
  2. Co komponent udostępnia swoim dzieciom jako props?
  3. Jak komponent reaguje na interakcje z użytkownikiem?
  4. Co dzieje się w metodach cyklu życia komponentu?

Czy zaledwie cztery proste pytania pozwalają na określenie kontraktu każdego komponentu? Pewnie nie, ale to świetny punkt wyjścia, który wystarczy Ci przy analizie standardowych przypadków.

A co jeżeli będziesz miał problem z udzieleniem odpowiedzi na któreś z pytań? Być może to ostrzeżenie o przeoczonych błędach w projekcie komponentu. Najczęściej taka sytuacja sygnalizuje, że komponent można rozbić na kilka mniejszych komponentów o bardziej precyzyjnej odpowiedzialności.

Definiujemy kontrakt komponentu

Światło reflektorów padnie na komponent kontenerowy App. Służy on jako centrum logik crypto-trackera, prostej aplikacji, którą zbudowałem przy okazji opisywania fundamentalnych koncepcji Reacta w ramach serii Pierwszy projekt w ReactJS.

Bez zbędnego przedłużania, zabieramy się za definiowanie kontraktu. Nie przestrasz się mechanicznych odpowiedzi w kolejnych akapitach. Obiecuję, że taka forma będzia miała pozytywny wpływ na dalszą pracę.

Co renderuje komponent?

Wszystkich odpowiedzi na to pytanie udzieli nam metoda render().

Komponent zawsze otacza całą swoją zawartość divem.

Komponent zawsze renderuje komponenty: Header, SearchBar, CoinList.

App nie korzysta z wyświetlania warunkowego. Jeżeli testowany przez Ciebie komponent renderuje różną zawartość w zależności od state/props/kontekstu, nie zapomnij uwzględnić tego w tym podpunkcie.

Co komponent udostępnia swoim dzieciom jako props?

Po raz kolejny zwracamy się ku render().

Komponent Header otrzymuje this.state.marketCap jako props.cap.

Komponent SearchBar otrzymuje this.onSearchQueryChanged jako props.handleChange oraz this.state.searchQuery jako props.searchQuery.

O. W przypadku CoinList mamy do czynienia z pierwszym rozgałęzieniem. Jeżeli this.state.matchedCryptos jest różne od null, komponent CoinList otrzymuje this.state.matchedCryptos jako props.cryptos. W przeciwnym wypadku otrzyma this.state.cryptos.

Poza tym CoinList otrzymuje this.state.isLoading jako props.isLoading.

Jak komponent/jego dzieci reaguje na interakcje z użytkownikiem?

Komponent bezpośrednio nie obsługuje żadnych interakcji, ale przekazuje this.onSearchQueryChanged do SearchBar. Sprawdzimy działanie onSearchQueryChanged w izolacji, a integracją z SearchBar zajmiemy się w kolejnym artykule.

Co dzieje się w metodach cyklu życia komponentu?

Komponent w metodzie cyklu życia componentDidMount fetchuje listę kryptowalut z zewnętrznego API.

Od kontraktu do testów

Jak widzisz, udzielone odpowiedzi w jasny sposób wskazały nam, co wymaga przetestowania. Jeżeli stawiasz pierwsze kroki w testach, możesz jeszcze nie zdawać sobie z tego sprawy, ale… najcięższe zadanie mamy już za sobą. Jedyne, co zostało do zrobienia, to przekucie języka naturalnego na przypadki testowe, co będzie duużo prostsze.

Zanim się za to zabierzemy, napiszemy prosty skrypt do uruchamiania testów jednostkowych. Zajrzyjmy do package.json, gdzie create-react-app z miejsca oferuje nam następującą komendę:

Jak widzisz, korzysta ona ze środowiska jsdom służącego do emulowania przeglądarki. Dopóki opieramy testy jednostkowe na shallow renderingu, jsdom będzie dla jedynie zbędnym obciążeniem. Stąd przyda się inna komenda, dzięki której testy będą wykonywały się szybciej:

Teraz możemy zabierać się za plik App.test.js. Zaczniemy od kilku funkcji pomocniczych, które ułatwią nam proces testowania. Oto one:

W testach będziemy potrzebowali zarówno shallow wrappera oraz dostępu do otoczonej przez niego instancji.

Na shallow wrapperze będziemy będziemy sprawdzali „statyczne” części kontraktu komponentu: to, co renderujemy, props które przekazujemy (pytania 1 i 2). Instancja pozwoli nam weryfikowanie zmian w stanie, który wywołują metody komponentu (pytania 3 i 4).

Do zwrócenia wrappera wykorzystuję funkcję pomocniczą app(disableLifecycleMethods = false). Enzyme od wersji v3 domyślnie wywołuje metody componentDidMount i componentDidUpdate przy shallow renderingu. W niektórych testach przyda nam się furtka wyłączająca to zachowanie.

Funkcje beforeEach i afterEach, nazywane w testerkim żargonie hookami, są wywoływane przed i po każdym przypadku testowym. Pierwsza zautomatyzuje inicjalizacje zmiennych, na których będziemy wykonywali asercje. Druga resetuje ich stan, co pozwala „na czysto” zacząć kolejny test.

W ramach rozgrzewki dodałem do naszego rusztowania pierwszy przypadek testowy, odpowiednik Hello World w Enzyme. To smoke test, który sprawdza, czy renderowanie App przebiega bez przeszkód (spoiler alert: u mnie działa :o).

Teraz możemy przejść do części właściwej, czyli przerabiania udzielonych wcześniej odpowiedzi na konkretne testy.

Testowanie renderowania

  • Komponent zawsze otacza całą swoją zawartość divem.

To zdanie rozbijemy na dwie asercje.

  • Komponent zawsze renderuje komponenty: Header, SearchBar, CoinList.

Tutaj sprawa jest prosta, każdy komponent otrzymuje swój przypadek testowy.

Testowanie udostępnianego stanu

  • Komponent Header otrzymuje this.state.marketCap jako props.cap.

  • Komponent SearchBar otrzymuje this.onSearchQueryChanged jako props.handleChange oraz this.state.searchQuery jako props.searchQuery.

Teraz zabierzemy się za przetestowanie dwóch scenariuszy przekazywania wartości do props.cryptos w komponecie CoinList. W tej sytuacji przyda nam się wyłączenie cyklu życia. Ma on wpływ na zmianę this.state.cryptos, a ja chciałbym skupić się na jego początkowej wartości.

Zagnieżdżone beforeEach jest wywoływane po beforeEach z zakresu otaczajacęgo, więc pozwala na nadpisanie stanu zmiennych.

  • this.state.matchedCryptos jest równe null.

  • this.state.matchedCryptos jest różne od null.

W myśl zasady DRY, odpuszczę sobie prezentację testu przekazania state.isLoading do CoinList ;).

Testowanie interakcji z użytkownikiem

Czas sprawdzić, czy metoda onSearchQueryChanged, przekazywana do SearchBar (co już zweryfikowaliśmy!), działa jak należy. Jako że wykonujemy testy jednostkowe App, to nie interesuje nas, czy SearchBar właściwie wykorzysta tę metodę. Przetestujemy ją w izolacji, poprzez symulacje jej poprawnego wywołania. Zadaniem oSQC jest ustawienie state.searchQuery do wartości eventu oraz wywołanie callbacka setMatchedCryptos.

Zacznijmy od hooków.

  • onSearchQueryChanged ustawia state.searchQuerydo wartości event.target.value.

  • onSearchQueryChanged wywołuje callbacka.

Z punktu widzenia kontraktu App równie ważna co samo wywołanie oSQC jest kwestia tego, czy callback setMatchedCryptosspełnia swoje zadanie. To właśnie ta metoda ma wpływ na warunek, który testowaliśmy w CoinList.

Do sprawdzenia mamy dwa scenariusze wykonania setMatchedCryptos: wartość searchQuery zostanie dopasowana z jakimś elementem cryptos lub nie.

Aby przeprowadzić działanie setMatchedCryptos, potrzebujemy czegoś więcej niż wyjściowego stanu state.cryptos = [].

Nasz problem rozwiąże moduł mockedCryptos, w którym umieścimy dane przygotowane na potrzebę testów. Będzie on również wykorzystywany w nadchodzących testach componentDidMount, więc zostawimy od razu dwie wersje obiektów:

  • Nieobrobioną, skopiowaną prosto z CoinMarketCap API (na potrzeby componentDidMount).
  • Przetworzoną przez funkcję mapFetchedCryptos (z niej skorzystamy za chwilę)

Tym razem hooki będą wyglądały następująco:

  • searchQuery jest dopasowane z elementem cryptos.

  • searchQuery nie jest dopasowane z elementem cryptos.

Powyższe testy wyglądają na poprawne, a jednak zapalą się na czerwono.

Czemu? Łatwo przeoczyć, że setMatchedCryptos wykorzystuje metodę debounce z biblioteki lodash. To funkcja ograniczająca częstotliwość wywoływania funkcji, co przydaje się w handlerach inputów.

Jej działanie jest problematyczne z punktu widzenia testów jednostkowych. Po pierwsze zależy nam na czasie wykonywania test case’a, nie chcemy marnować 250 ms. Po drugie nie chcemy wcale testować metody zewnętrznej biblioteki. To obowiązek jej twórców.

W takich sytuacjach z pomocą przychodzi mockowanie, czyli technika zastępowania funkcji jej atrapą o zmodyfikowanym, uproszczonym działaniu.

Mamy do dyspozycji wiele sposobów na tworzenie mocków w Jest. Na żądnych wiedzy na końcu wpisu czeka link da artykułu, który zawiera przegląd tych metod. W ramach tego wpisu przedstawię dwa najczęściej wykorzystywane warianty mockowania. Pierwszy z nich, czyli mockowanie pojedynczej funkcji, przyda się właśnie teraz.

Aby zmockować funkcję, musimy zaimportować jej prawdziwą wersję do zakresu testów, a następnie skorzystać z jest.mock() oraz jest.fn().

Dzięki temu, kiedy Jest natrafi na debouce podczas wykonywania testowanego kodu, podmieni jego implementację na tę, którą przekazałem jako drugi parametr do jest.mock.

Ważne: pamiętaj o umieszczeniu jest.mock w zakresie globalnym, przed pierwszym describe – inaczej nie zadziała!

Teraz testy w tej sekcji działają jak należy. Oczywiście zawarte powyżej przypadki testowe nie pokrywają działania setMatchedCryptos w stu procentach. W końcu nie mamy pewności jak funkcja poradzi sobie jeżeli dopasowany zostanie więcej niż jeden element etc. Na swoją obronę przypominam, że celem tego wpisu nie jest pokrycie kodu crypto-trackera w stu procentach, więc przejdźmy dalej z czystym sumieniem ;).

Testowanie efektów ubocznych

Pozostało nam przetestowanie componentDidMount:

Sprawa jest prosta: pobieramy dane z API CoinMarketCap, mapujemy je do odpowiedniego formatu i ustawiamy wynik w state.cryptos. Oczywiscie kontakt z API w testach jednostkowych wiązałby się z niepotrzebnymi stratami czasowymi. Poza tym chcemy mieć pewność, że to nasza logika, a nie API, działa jak należy.

Po raz kolejny musimy skorzystać z mockowania. Aby było to możliwe, musimy wyodrębnić logikę fetchowania danych do osobnego modułu, który następnie zmockujemy. To jedna z sytuacji, w których testowanie wymusza lepszą architekturę. Zawsze warto ukrywać skąd i w jaki sposób pobierane są dane. Komponent nie musi wiedzieć o takich szczegółach implementacyjnych.

Wspomniany moduł utworzyłem pod ścieżką src/api/coinMarketCap.js, wygląda następująco:

componentDidMount przekształciłem do takiej formy:

Teraz możemy zmockować getCoinList.

Pójdziemy o krok dalej niż w przypadku mocka debounce i zmockujemy cały moduł zamiast pojedynczej funkcji. Jest to możliwe za sprawą manualnych mocków.

Zaczniemy od utworzenia podfolderu __mocks__ (dokładnie taka nazwa jest wymagana przez Jest) w folderze zawierającym mockowany przez nas moduł. W tym konkretnym przypadku to src/api/__mocks__.

W __mocks__/coinMarketCap.js tworzymy uproszczoną implementację getCoinList. Będzie ona zwracała promise’a wywiązujacego się obiektem mockedCryptos.

Teraz pozostało nam zaimportować prawdziwą wersję getCoinList do zakresu App.test.js i skorzystać z jest.mock(). Jako argument podajemy ścieżkę prawdziwego modułu. Wiem, że to mało intuicyjne (a na dodatek słabo opisane w dokumentacji :(), ale Jest sam zajrzy do podfolderu __mocks__ i wyszuka przygotowaną przez nas atrapę. Uda mu się tylko, jeżeli nazwy modułów będą takie same, więc zwróć na to uwagę.

Okej, mamy gotowego mocka – możemy napisać przypadek testowy.

  • componentDidMount pobiera dane z API oraz zapisuje je, po odpowiednim sformatowaniu, w this.state.cryptos.

Skąd konieczność korzystania z setTimeout przy testowaniu atrapy? Musimy dać silnikowi okazję na wywiązanie Promise’a, a setTimeout przekazuje nasz kod do event loopa, który czeka na zwolnienie stacka. To temat na oddzielny wpis, póki co mam do zaoferowania kolejny link w materiałach uzupełniających na końcu artykułu.

I… to wszystko! Kontrakt App został pokryty testami, misja zakończona sukcesem. Cały kod źródłowy App.test.js znajdziesz tutaj.

Materiały uzupełniajace i źródła

Podsumowanie

Po przeczytaniu tego wpisu wiesz już:

  • Czym jest kontrakt komponentu?
  • Jak zdefiniować go za pomocą czterech prostych pytań?
  • Co składa się na 20% interfejsu Enzyme i Jest, które wykorzystuje się w 80% testów jednostkowych?
  • Jak korzystać z spyów i mocków?

Nie ma na co czekać, zastosuj zdobytą wiedzę w praktyce. Weź na warsztat jakikolwiek napisany przez siebie komponent, wypisz na kartce jego kontrakt i bierz się za implementacje testów. Powodzenia :).

W kolejnym wpisie zajmiejmy się testami integracyjnymi App i jego dzieci.

Podobał Ci się dzisiejszy artykuł? Udostępnij go w Twoich ulubionych mediach społecznościowych. Może znajdzie się ktoś, dla kogo również będzie wartościowy.

Bądź na bieżąco. Wystarczy polubić fanpage AlgoSmart na fejsie, obserwować mój profil na Twitterze i regularnie odwiedzać portal Polski Front-end.

Zdjęcie tytułowe autorstwa: unsplash-logoHelloquence

Marcin Czarkowski

Cześć! Ja nazywam się Marcin Czarkowski, a to jest AlgoSmart - blog, na którym dzielę się wiedzą o ReactJS, JavaScript oraz CSS. Od niedawna tworzę materiały na YouTube, warto rzucić okiem :).