Testowanie aplikacji frontendowych. Co warto na ten temat wiedzieć, a o czym lepiej zapomnieć
Testowanie aplikacji to rozległy temat, szczególnie gdy umieścimy go w dynamicznie zmieniającym się środowisku, jakim jest frontend. Rodzaje testów możemy w prosty sposób wygooglować, jednak to, jak i kiedy je zastosować, to zupełnie inna bajka. Chciałbym wprowadzić Was w świat testowania aplikacji frontendowych, poruszając przy okazji kilka mniej oczywistych, choć kluczowych, kwestii. Zależy wam na zminimalizowaniu liczby niepotrzebnych pomyłek i jak najlepszej inwestycji czasu? O tym, jak to zrobić, dowiecie się z tego artykułu.
Czy testy są nam w ogóle potrzebne?
Można by pomyśleć, że skoro pisanie testów kosztuje (czas) to lepiej skupić się na rozwoju produktu i tworzeniu nowych funkcjonalności, prawda?
W krótkiej perspektywie takie myślenie ma sens, na dłuższą metę jednak okazuje się, że dzięki pisaniu testów oszczędzamy czas, który spędzilibyśmy na debugowaniu. Największą zaletą testów jest jednak to, że dają nam one spokój. Osobiście, wolę wyłapać błąd od razu, dzięki napisanym wcześniej testom niż o trzeciej nad ranem otrzymać telefon z informacją, że aplikacja przestała działać.
Oczywiście, po napisaniu nowego kodu, można by za każdym razem ręcznie „przeklikać” całą aplikację i upewnić się, że wszystko jest w porządku, tylko po co tracić czas na czynności, które można zautomatyzować?
Rodzaje testów i ich zastosowanie
Rozróżniamy cztery grupy testów:
- Testy Statyczne (Static Tests)
- Testy Jednostkowe (Unit Tests)
- Testy Integracyjne (Integration Tests)
- Testy End to End (czasem nazywane także testami funkcjonalnymi, lub e2e)
Testy Statyczne (Static Tests)
Jest to grupa testów, która w praktyce nie polega na pisaniu kodu, a jedynie zastosowaniu narzędzi takich jak eslint, czy TypeScript.
Eslint wyłapie dla nas w kodzie różnego rodzaju literówki i inne błędy, wynikające z niedopatrzenia. Zadba także o to, by zespół stosował się do tych samych zasad w pisaniu kodu.
TypeScript zajmuje się sprawdzaniem kodu pod kątem potencjalnych błędów związanych z typami zmiennych (np. czy do funkcji, spodziewającej się dwóch liczb, nie przesłaliśmy przypadkiem tablicy stringów). Poleciłbym jego zastosowanie w 90% przypadków – jeżeli robisz tylko drobny projekt na studia (i nie miałeś wcześniej do czynienia z tym narzędziem) to nie zawracaj sobie nim głowy, jednak wraz z rozmiarem projektu, zalety TypeScript szybko zaczynają przysłaniać jego drobne wady.
Testy Jednostkowe (Unit Tests)
Testy jednostkowe mają za zadanie sprawdzić, czy każda indywidualna i wyizolowana przez nas cząstka aplikacji działa tak jak powinna. Wyizolowana, a więc taka, dla której wszelkie zależności z innymi częściami aplikacji zostają zamockowane (zastąpione uproszczonym kodem, lub nieprawdziwymi danymi). Z jednej strony, pisze się je szybko i bez większych problemów można sprawdzić, jak komponent zachowa się w różnych sytuacjach. Z drugiej strony, taka izolacja wymusza na nas pewne ograniczenia, uniemożliwiające nam przetestowanie niektórych funkcjonalności.
Do pisania testów jednostkowych polecam połączenie Jest z Testing Library.
Testy Integracyjne (Integration Tests)
Dzięki nim możemy zweryfikować czy kilka oddzielnych komponentów działa ze sobą w harmonii. Pisanie ich jest bardziej czasochłonne od testów jednostkowych, za to narzucają one na nas mniej ograniczeń.
Przy tworzeniu testów integracyjnych, powinniśmy ograniczyć mockowanie do minimum. Podmieniać będziemy głównie network requesty (do czego możemy użyć np. MSWjs) i animacje (szkoda tracić na nie czasu podczas testowania).
Do testów integracyjnych skorzystałbym z tych samych bibliotek, co w przypadku unit testów (Jest i Testing Library).
Testy End to End
Testy E2E to tak naprawdę swego rodzaju robot, zaprogramowany przez nas do „przeklikiwania” najważniejszych funkcjonalności naszego produktu, by upewnić się, że wszystko z nimi w porządku. Testy te, najczęściej, korzystają z całej aplikacji (frontendu i backendu) i powinniśmy pisząc je unikać jakiegokolwiek mockowania.
Zakładamy tutaj przypadek użytkownika, który wie, jak używać naszej aplikacji, a my chcemy się tylko upewnić, że nie przeszkodzi mu w tym nieoczekiwany błąd. Nie próbujemy sprawdzać wszystkich edge case’ów, jakie tylko przychodzą nam do głowy (w tym celu lepiej sprawdzają się testy integracyjne i jednostkowe).
Do testów E2E polecam Cypress.io w połączeniu z Cypress Testing Library.
Coś za coś
Jeśli do pomalowania pokoju postanowisz użyć tylko dużego wałka, nigdy nie dotrzesz farbą do trudno dostępnych miejsc. Jeśli użyjesz małego pędzla, malowanie zajmie Ci całe lata, a efekt końcowy będzie co najwyżej „zadowalający”. Każde narzędzie zostało stworzone w konkretnym celu i tak jak malarz, do profesjonalnego pomalowania pokoju, użyje różnych wałków i pędzli, tak i my powinniśmy zawsze dobierać rodzaj testu do sytuacji, w której się znaleźliśmy.
Narzędzia takie jak Eslint, czy TypeScript sprawdzą za nas literówki i zgodność typów, ale jakakolwiek logika biznesowa jest poza ich zasięgiem. Unit testy sprawdzą, czy logika jednego z naszych komponentów została napisana poprawnie, jednakże nie dadzą nam żadnej informacji o tym, czy komponent ten komunikuje się odpowiednio z inną częścią aplikacji, od której zależy. W takiej sytuacji świetnie sprawdzą się testy integracyjne, które jednak nie zweryfikują, czy frontend poprawnie komunikuje się z backendem i czy odpowiednio reaguje na potencjalne błędy. W tym celu możemy skorzystać z testów End to End, które mają duże możliwości, jednak ze względów praktycznych nawet one najczęściej nie testują aplikacji w środowisku produkcyjnym.
„Write tests. Not too many. Mostly integration”
Jakiś czas temu natrafiłem na te trzy krótkie zdania, gdzieś w internecie i zapamiętałem je, ponieważ w bardzo zwięzły sposób opisują przemyślenia, do których sam doszedłem.
Write tests.
Testy są ważne i nie ulega wątpliwości, że każdy, kto ceni swój czas i spokój powinien z nich korzystać.
Not too many.
Zdarza się, że zespoły, a czasem nawet całe firmy ustalają sobie z góry za cel, żeby 100% kodu zostało pokryte testami. Choć brzmi to jak dobra praktyka, w większości przypadków nie ma większego sensu. Problem polega na tym, że powyżej pewnej granicy (ok. 70-80%, choć opieram tę liczbę bardziej na własnym doświadczeniu niż jakichkolwiek badaniach) coraz częściej zaczynamy testować rzeczy, które wcale nie powinny być testowane. Bywa, że jest to kod niezawierający żadnej logiki,
z którym równie dobrze (jak nie lepiej) poradzą sobie Eslint i TypeScript, a czasami zaczynamy testować nasz sposób implementacji jakiejś funkcjonalności, a nie to, czy działa ona poprawnie z perspektywy użytkownika.
W ten sposób niepotrzebnie mnożymy sobie problemy. Zainwestowaliśmy w napisanie tych testów nasz czas, kodu do utrzymania mamy teraz więcej, a pewności, że nasza aplikacja działa, jak należy tyle samo, co na początku. Co gorsza, jeśli wplątaliśmy się w testowanie implementacji, będziemy musieli naprawiać nasze testy za każdym razem, kiedy zmieni się sposób implementacji jakiejś funkcjonalności pomimo tego, że z perspektywy użytkownika nic się nie zmieniło. W większości przypadków, testy raz napisane nie powinny od nas wymagać żadnej ingerencji w przyszłości.
W przypadku testów zasada „im więcej, tym lepiej” nie działa, nie oznacza to jednak, żeby wszystko testować powierzchownie. Kłania się tutaj zasada Pareto – wysoce prawdopodobne jest, że 80% satysfakcji użytkownika wynikać będzie z 20% funkcjonalności, jakie zapewnia nasza aplikacja. Są to kluczowe elementy i jeśli tylko uda nam się je rozpoznać (nie zawsze jest to takie oczywiste) to powinniśmy przetestować je naprawdę dokładnie.
Mostly integration.
Ten typ testów klasyfikuje się w samym środku pomiędzy unit testami a testami E2E. Pisze się je stosunkowo szybko, a jednocześnie nie są one pozbawione kontekstu, którego brakuje testom jednostkowym.
Focus on use cases, not code itself.
To zdanie dodałbym od siebie. Zaobserwowałem, że większość rozterek, dotyczących tego, jaki rodzaj testów wybrać rozwiewa się, kiedy na zadanie spojrzymy z perspektywy, nie kodu samego w sobie, a jego przeznaczenia. W Ericsson, w zespole, do którego należę, często korzystamy z tzw. „User Stories”. Chodzi w nich o to, aby zadanie do wykonania opisać w formie czyjegoś życzenia.
Przykład:
Jako użytkownik, chciałbym mieć możliwość dodawania wybranego produktu do koszyka.
Tak sformułowane zadanie jest przez nasz zespół później rozbijane na czynniki pierwsze – zastanawiamy się jakie komponenty mogą nam być potrzebne, czy zmiany będą dotyczyć tylko frontu, czy również backendu itp.
I tak sobie myślę, dlaczego do testowania nie mielibyśmy podchodzić w analogiczny sposób? Mamy przed sobą zadanie, konkretną funkcjonalność do zaimplementowania, więc celem naszych testów powinno być upewnienie się, że zadanie to zostało zrealizowane oraz że w razie, gdyby w przyszłości przestało działać, zostaniemy o tym natychmiast powiadomieni.
Ta drobna zmiana perspektywy sprawia, że dużo łatwiej jest dość do tego, jaki rodzaj testu jest nam potrzebny w tej konkretnej sytuacji. Czy nasza funkcjonalność dotyka tylko jednego komponentu i możemy ją przetestować za pomocą testów jednostkowych? A może dotyka większej części aplikacji, ale wciąż możliwe jest wyizolowanie takiego scenariusza przy użyciu testów integracyjnych? Potrzebny nam będzie dostęp do pełnego kontekstu? W takim wypadku najlepiej sprawdza się testy E2E.
Podsumowując:
- Testy są ważne.
- Skupmy się na najważniejszych funkcjonalnościach naszego produktu i przetestujmy je naprawdę dokładnie.
- Testujmy nie kod, a realne przypadki jego użycia.
Na koniec, Drogi Czytelniku, chciałbym Ci podziękować za to, że poświęciłeś czas na przeczytanie tego tekstu do samego końca. Mam również nadzieję, że udało mi się zwrócić twoją uwagę na kilka aspektów testowania, o których rzadko się mówi, a które mają kluczowy wpływ na efekt końcowy naszej pracy.
Jeżeli podczas czytania nasunęły Ci się jakieś pytania lub przemyślenia, chętnie odpowiem na nie w komentarzach. Powodzenia!