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:
- Co renderuje komponent?
- Co komponent udostępnia swoim dzieciom jako
props
? - Jak komponent reaguje na interakcje z użytkownikiem?
- 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ść div
em.
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
otrzymujethis.state.marketCap
jakoprops.cap
.
- Komponent
SearchBar
otrzymujethis.onSearchQueryChanged
jakoprops.handleChange
orazthis.state.searchQuery
jakoprops.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ównenull
.
this.state.matchedCryptos
jest różne odnull
.
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.searchQuery
do wartościevent.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 setMatchedCryptos
speł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 elementemcryptos
.
searchQuery
nie jest dopasowane z elementemcryptos
.
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, wthis.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
- The Right Way to Test React Components
- Understanding Jest Mocks
- Learning Log – Week #6 – krótko o event loopie w JS.