W ostatnim projekcie, w którym brałem udział CTO wymagał od programistów jak największego pokrycia kodu testami. W zasadzie sugerował, żeby dążyć do 100% test coverage.
W związku z tym przez ostatnie kilka miesięcy musiałem nauczyć się, w jaki sposób testować funkcje, komponenty oraz ekrany aplikacji w React Native. Ogólnie testowania aplikacji mobilnych.
W tym artykule postaram się przekazać część tego, czego się nauczyłem:
- opowiem, po co w zasadzie pisać testy,
- jak pisać testy jednostkowe oraz snapshotowe,
pokrótce przybliżę framework JEST,
oraz omówię bibliotekę react-native-testing-library.
Po co pisać testy?
Programista bez buga w kodzie, jest jak żołnierz bez karabinu.
Błędy się nam zdarzały, zdarzają i będą zdarzać – są nieuniknione. To, czego można jednak uniknąć, to dostarczania aplikacji z błędami do rąk klienta.
Możemy oczywiście polegać na testowaniu manualnym – ale tester manualny też jest człowiekiem, nie wyłapie wszystkich nieścisłości w aplikacji.
Po co pisać testy?
Dobrze napisane testy dadzą nam pewność, iż nasz kod pozbawiony jest błędów, co więcej zapewnią nas, iż funkcjonalności, które pisaliśmy X miesięcy temu wciąż działają po wprowadzeniu zmian w innych częściach kodu, refactorze, czy przejściu na inną wersję którejś z używanych bibliotek.
Testy mogą służyć również za swoistą “dokumentację” projektu.
Często łatwiej jest zrozumieć, co robi dana funkcja czy komponent patrząc na kod jego testu, niż na sam kod funkcji.
Na tym swoją filozofię opierał wspominany przeze mnie wcześniej CTO, który uważał, iż dodanie testów umożliwia nietechnicznemu Product Ownerowi lepsze zrozumienie tego, co robią wprowadzone zmiany, a 100% test coverage zapewni go, iż wprowadzone zmiany faktycznie działają.
Niektórzy podają jako argument, iż pisanie testów automatycznych oszczędza czas na manualnym QA.
Z tym nie do końca się zgodzę.
Test danej funkcjonalności faktycznie wystarczy napisać raz, natomiast manualnie wypadałoby testować ją za każdym razem gdy wprowadzamy jakieś zmiany w kodzie, jednak w pewnych przypadkach pisanie testu bywa dość czasochłonne.
Podsumowując
Czy warto pisać testy?
Jak najbardziej, jeszcze jak!
Czy przy mniejszych projektach warto dążyć do 100% test coverage?
Raczej nie.
Nie ma sensu na siłę starać się przetestować każdego zakamarka kodu – szczególnie jeżeli napisanie testu miałoby zająć więcej niż napisanie samej funkcjonalności.
Czym są snapshoty?
Po stworzeniu nowego komponentu generujemy dla niego snapshot. Jako iż wcześniej nie istniał inny snapshot danego komponentu, nie następuje porównanie snapshotów, a wygenerowany snapshot zostaje zapisany.
Następnie za każdym razem gdy testujemy aplikację generowany jest nowy snapshot danego komponentu i porównywany z zapisanym.
Jeśli snapshoty różnią się, test kończy się niepowodzeniem. Możemy wtedy sami stwierdzić czy zmiana była intencjonalna.
Jeśli była - uaktualniamy snapshot, żeby nowa wersja komponentu była używana do porównania.
Jeśli nie - poczynimy odpowiednie zmiany w kodzie.
Warto zobrazować to na konkretnym przykładzie.
Do stylowania komponentów oraz implementacji theme aplikacji wykorzystamy styled-components.
Tworzenie testów snapshotowych umożliwia framework JEST, który dołączany jest domyślnie do projektów React Native.
Weźmy na przykład prosty komponent guzika napisany z użyciem styled components, którego kolor tekstu oraz kolor tła zdefiniowane są w Theme aplikacji.
Testy powinny mieć następującą nazwę:
’${NazwaTestowanegoKomponentu/Funckji/Screena}-test.tsx`
(z rozszerzenim .ts jeżeli testujemy kod niezawierający tagów tsx).
W celu utworzenia pojedynczego testu wykorzystajmy funkcję test, która jako swoje argumenty przyjmuje nazwę testu oraz funkcję, dzięki której będziemy testować komponent.
Nazwa testu powinna jednocześnie wskazywać na to co dany test sprawdza. Wiele podobnych testów możemy łączyć w bloki dzięki funkcji describe. Wiele testów możemy łączyć w blok describe.
Pierwszy test snapshot
Stwórzmy pierwszy test snapshot w celu sprawdzenia, czy button renderuje się poprawnie.
Opis funkcji i komend
Komenda renderer.create() renderuje komponent, który następnie konwertowany jest do snapshota metodą to JSON.
Funkcja expect().toMatchSnapshot() sprawdza, czy wygenerowany wcześniej snapshot odpowiada zapisanemu, o ile istnieje zapisany snapshot.
.toMatchSnapshot() to tak zwany matcher - funkcja JEST wykorzystywana do porównywania/ testowania wartości na różny sposób.
Więcej na temat matcherów dowiesz się w dalszej części artykułu.
Testy uruchamia się dzięki komendy yarn jest.
Taki test zakończy się niepowodzeniem, Theme aplikacji, od którego zależy wygląd komponentu, nie będzie w nim widoczny.
Żeby rozwiązać ten problem, należy owinąć komponent w provider motywu.
Rezultatem jest utworzenie pliku SimpleButton-test.snap (ponieważ wcześniej nie istniał żaden snapshot).
Spróbujmy teraz zmienić jeden z kolorów w theme aplikacji, a następnie uruchomić napisany wcześniej test.
Test zakończy się niepowodzeniem.
Jak widać na powyższym zrzucie ekranu, JEST informuje nas dokładnie jakie zmiany zaszły w snapshocie, dzięki temu możemy sprawdzić, czy były one intencjonalne.
W tym przypadku jeżeli nie chcielibyśmy, aby zmiana koloru w theme wpłynęła na komponent, powinniśmy dodać nowy kolor i przypisać go jako kolor tła guzika.
Jeśli jednak zmiana była intencjonalna, możemy użyć flagi -u, aby zaktualizować kolor w snapshotach.
Testy snapshotowe umożliwiają programiście zdeterminowanie jak zmiany w kodzie oddziałują na zmiany w UI oraz zapobiegają niespodziewanym zmianom w UI.
Zaletą testów snapshotowych jest ich prostota oraz szybkość pisania.
Główną wadą jest to, iż snapshoty są testami porównawczymi.
Co za tym idzie przed stworzeniem pierwotnego snapshota danego komponentu, z którym porównywane są wszystkie kolejne snapshoty, trzeba upewnić się, iż dany komponent wygląda i działa prawidłowo (najczęściej poprzez manualne testowanie).
JEST matchers i testowanie funkcji
W poprzednim rozdziale poznaliśmy matcher toMatchSnapshot pozwalający na sprawdzenie, czy generowany snapshot jest zgodny z zapisanym.
JEST oferuje nam znacznie więcej różnorodnych matcherów. Rzecz jasna matcher wybieramy w zależności od naszego przypadku testowego.
Napiszmy kolejny test dla prostej funkcji
Banalna funkcja, która zwraca nam wartość dzielenia dwóch argumentów, a i b, chyba iż b jest równe 0 (wtedy rzuca błąd).
Przetestujmy najpierw czy funkcja faktycznie rzuci błąd w przypadku gdy b===0.
Swtórzmy plik exampleFunction-test.tsx w folderze __tests__ i napiszmy prosty test z użyciem matchera toThrow.
W teście sprawdzamy, czy exampleFunction z argumentami 1 i 0 faktycznie rzuci błąd.
exampleFunction owinięte jest w funkcję strzałkową, ponieważ w innym przypadku JEST nie mógłby wyłapać rzucanego błędu. Więcej informacja o tym znajdziecie w dokumentacji JEST.
Możemy również przetestować, czy wiadomość błędu jest prawidłowa. W tym celu możemy dodać string lub wyrażenie regularne jako argument matchera toThrow().
Jeśli wpiszemy string - JEST sprawdzi, czy wiadomość błędu zawiera wpisany string.
Jeśli wpiszemy wyrażenie regularne - sprawdzi, czy pasuje do wyrażenia.
Efekty uruchomionej komendy
Efektem uruchomienia komendy jarn jest, jest uruchomienie wszystkich testów. Jeśli chcemy uruchomić jedynie jeden z testów, wystarczy wpisać jego nazwę np. yarn jest exampleFunction-test.tsx
Możemy również sprawdzić, czy wartość zwracana przez funkcję jest zgodna z oczekiwaną. W tym celu można użyć matchera toBe lub toEqual.
Możemy również sprawdzić, czy wynik funkcji nie jest równy innej wartości, do tego może posłużyć matcher not.
W naszym przypadku to czy użyjemy toBe, czy toEqual nie ma znaczenia, różnica pojawiłaby się gdybyśmy porównywali obiekty.
toBe sprawdzi identyczność referencji instancji obiektów.
toEqual porówna wszystkie wartości pól obiektu.
Pokażę to na przykładzie
Zmodyfikujmy naszą przykładową funkcję tak, aby zwracała obiekt zawierający sumę oraz iloraz argumentów i zapiszmy ją jako nową funckję exampleFunction2.
Napiszmy następujący test, żeby zobrazować różnicę pomiędzy toEqual, a toBe.
Testowanie asynchronicznego kodu
Do tego przykładu użyjmy klienta axios oraz otwartego API – Agify.
Zainstalujmy axios komendą yarn add axios, a następnie stwórzmy przykładowe zapytanie do API.
Efektem wywołania takiego zapytania jest zwrócenie obiektu:
Sposoby testowania asynchronicznego kodu
Omówimy teraz trzy sposoby testowania asynchronicznego kodu.
Owinięcie testu w async/await
Możemy poczekać na dane z API, a następnie używając odpowiedniego matchera porównać je do przewidywanej wartości.
Zwrócenie Promise i wywołanie funkcji expect w bloku then()
Użycie matcherów .rejects/ .resolves
Powodują one, iż JEST czeka odpowiednio na odrzucenie lub rozstrzygnięcie promise.
Z mojego doświadczenia wynika, iż testy asynchroniczne psują się przy używaniu JEST fake timers.
Z tego względu warto przed takimi testami wywołać jest.useRealTimers() lub jest.useFakeTimers(“legacy”).
W tym celu można sięgnąć po funkcje służące do setupu testów. JEST dostarcza nam 4 funkcje, które (jak same nazwy wskazują) umożliwiają wykonanie wybranego kodu przed lub po wszystkich kodach oraz przed i po wszystkich testach.
- beforeAll,
- afterAll,
- beforeEach,
- afterEach.
W tym przypadku chcemy ustawić realTimers przed wszystkimi testami.
Mocks
Możesz się poczuć teraz lekko oszukany, ale…
testowanie “strzałów” do API w sposób przedstawiony powyżej nie jest najlepszym pomysłem.
Głównymi wadami takiego podejścia jest to, iż testy są wolniejsze, niestabilne, oraz powodują niepotrzebne obciążenie backendu. W rozwiązaniu tych problemów pomogą nam mocks.
Funkcje mock to nic innego, jak funkcje, które w pewien kontrolowany sposób udają zachowanie innych funkcji na potrzeby środowiska testowego. Mockować można również całe moduły.
W naszym przypadku możemy zmockować moduł axios wywołując jest.mock(“axios”),
a następnie dzięki mockResolvedValue
ustawić przewidywaną wartość odpowiedzi.
W tym przypadku imitujemy samą zwracaną wartość funkcji axios.get.
Możemy również imitować całą implementację tej funkcji. Z pomocą przyjdzie nam mockImplementation().
Innym sposobem na utworzenie mock funkcji jest użycie jest.spyOn. W ten sposób możemy zmockować np. funkcję zwracającą obecną datę.
Przydaje się to np. gdy tworzymy snapshoty komponentu, który wyświetla ciąg znaków zależnych od obecnej daty.
Snapshoty takiego komponentu różniłyby się od siebie w zależności od tego, w jaki dzień zostaną zrobione, co nie jest pożądanym zachowaniem.
Ten sam efekt można osiągnąć stosując jest.fn().
W tym przypadku jedyną różnicą pomiędzy jest.fn, a jest.spyOn jest to, iż stosując jest.spyOn możemy przywrócić pierwotną implementację mockowanej funkcji dzięki metody mockRestore().
Podsumowując
Mockowanie to technika umożliwiająca testowanie części kodu, które korzystają z różnych zależności np. modułów, dzięki zastępowaniu ich obiektami/funkcjami, które możemy kontrolować i badać.
jest.mock - stosujemy do mockowanie modułów.
jest.fn - stosujemy do mockowania funkcji.
jest.spyOn - stosujemy do mockowania funkcji, dodatkowo umożliwia nam przywrócenie pierwotnej implementacji.
React-native-testing-library
Kolejnym narzędziem, które można używać do testowania JEST react-native-testing-library.
Nie jest ono domyślnie dołączone do react-native, więc wymaga osobnej instalacji.
Główną funkcją tego narzędzia jest tworzenie testów skupionych na użytkowniku i na tym, w jaki sposób wchodzi on w interakcję z aplikacją. Narzędzie to więc świetnie nadaje się do testowania akcji użytkownika i reakcji aplikacji na te akcje.
Działanie react-native-testing library
Dostarcza metodę fireEvent(), która przyjmuje jako argument wyrenderowany komponent oraz akcję, która ma na nim zostać wykonana. To właśnie dzięki niej możemy sprawdzać, czy nasza aplikacja prawidłowo reaguje na interakcje ze strony użytkownika.
Napiszmy teraz przykładowy ekran zawierający, napisany wcześniej przycisk SimpleButton, którego naciśnięcie spowoduje wywołanie funkcji axiosCall, a dane otrzymane z API zostaną wyświetlone w komponentcie <Text> powyżej guzika.
A teraz napiszmy test tego ekranu (analogicznie do tego, jak pisaliśmy poprzednie testy). Jedyną różnicą będzie użycie renderera z testing-library-react-native, a nie JEST.
Efektem tego testu jest zapisanie następującego snapshota.
Zwróć uwagę, iż ekran wyświetla teraz domyślne wartości age: 0 i name: noname.
Spróbujmy teraz sprawdzić, czy UI reaguje poprawnie na naciśnięcie przycisku. Zacznijmy od zmockowania funkcji axiosCall tak jak robiliśmy to wcześniej.
Następnie zasymulujmy naciśnięcie przycisku przez użytkownika.
Teraz wykonajmy snapshot, który zobrazuje wygląd ekranu po naciśnięciu przycisku. Musimy w tym celu poczekać na to, aż funkcja mockAxiosCall zostanie wywołana.
W tym celu możemy użyć funkcji waitFor, która jak sama nazwa wskazuje – powoduje, iż test czeka na jakieś wydarzenie.
Aby sprawdzić czy funkcja mockAxiosCall została wywołana, możemy użyć matchera toHaveBeenCalledTimes(1).
Aby test działał poprawnie musimy cały test owinąć w async.
Na koniec, gdy funkcja waitFor zaczeka na wywołanie mockAxiosCall musimy utworzyć kolejny snapshot
Cały test powinien wyglądać następująco:
Efektem wywołania testu jest powstanie następującego snapshota.
Uwaga
W tym snapshocie wartości age i name pochodzą już z funkcji mockAxiosCall, co potwierdza, iż UI reaguje poprawnie na akcje użytkownika.
Podsumowanie
Mam nadzieję, iż ten artykuł przybliżył Wam, w jaki sposób można pisać podstawowe testy swojej aplikacji.
Najważniejsze zalety i funkcje testów:
Tworzenie testów snapshotowych wszystkich komponentów i ekranów -> ponieważ niskim kosztem można zabezpieczyć się przed wprowadzaniem przypadkowych zmian we wcześniej utworzonych częściach aplikacji, w czasie gdy pracujemy nad nowymi.
Tworzenie testów jednostkowych wszystkich utility funkcji -> ponieważ w łatwy sposób wywołując w teście funkcje z przykładowymi argumentami i sprawdzając, czy wynik wywołania jest zgodny z przewidywanym, możemy sprawdzić, czy funkcja działa poprawnie.
Tworzenie snapshotów w reakcji na interakcje użytkownika -> ponieważ możemy upewnić się, iż akcje użytkownika prowadzą do przewidywanych zmian w UI.
Link do repozytorium z kodem
https://github.com/dogtronic/blog-react-native-testing