You are on page 1of 11

Design Testów

Frontendowych
Design Testów
Frontendowych
Po co nam testy? To oczywiste! Testy piszemy po to, aby chroniły nas przed regresją
funkcjonalności, aby refactory kodu były stabilne i - może przede wszystkim - aby
długoterminowe utrzymywanie aplikacji było łatwiejsze.

😉
Hmm, łatwo powiedzieć (lub napisać) ... ale kiedy zejdziemy do poziomu szczegółów,
😉
to sprawy przestają być takie oczywiste. Z frazesami jest zawsze łatwo . Dodatkowo
- czynniki takie jak np. „łatwość utrzymania” dla różnych developerów - a także w zależ-
ności od specyfiki projektu - może oznaczać co innego. I w efekcie pisanie „wartościo-
wych testów” będzie wyglądało inaczej. Co jest „dobre”, a co „złe” - jest kontekstowe.

Nie istnieje jedna, doskonała receptura na pisanie optymalnych i doskonałych testów.


😏
„Doskonałe testy” phi... A rady typu „zawsze rób X”, „nigdy nie rób Y” - najlepiej
schować między bajki. Najczęściej musimy wybierać „coś za coś”, iść na kompromisy
- a co jest w danej sytuacji lepsze, to zależy... ale - no właśnie - od czego to zależy?
🤔 . Jesteśmy w stanie wylistować kilka czynników do uwzględnienia - i pytań, na które
warto odpowiedzieć, zanim zaczniemy pisać w naszym projekcie testy na dużą skalę.
Część 1:
Jak duży obszar chcę
testować?
W uproszczeniu, testy możemy podzielić na: jednostkowe, integracyjne i end-to-end.
Jeśli piszesz nową funkcjonalność, na którym poziomie należy pokryć ją testami?

Klasyczna piramida testów sugeruje, aby wśród testów najliczniejsze były testy
jednostkowe, w drugiej kolejności - integracyjnych, a najmniej - E2E. Wynika to przede
wszystkim z założenia, że im większy obszar systemu testujemy, tym dłużej test będzie
trwał - oraz że tym bardziej taki test będzie kruchy. Bo skoro przechodzi przez więcej
elementów, to ma więcej powodów, aby się wywalić, jeśli którykolwiek element się
zmieni. Logiczne.

Tylko jeśli dobre praktyki backendowe przyłożymy do świata frontendu, to okazuje


się, że testy integracyjne UI - owszem, mają często bardziej skomplikowany setup,
niż testy unitowe. Ale wcale nie muszą trwać dużo dłużej. O ile różnice w prędkości
w ogóle będą odczuwalne. Jeśli zatem speed is not the issue, to... unity czy integracja? 🤔
🎉
Jeśli testujemy jednostkowo, to w naszym teście uczestniczy, hm... jednostka i nic
poza nią. Jeśli mam komponent z formularzem i buttonem, to mogę przetestować np.
czy po kliknięciu tego buttona, komponent wywoła callbacka (jeśli React - lub wyemi-
tuje event, jeśli Angular itp). Mogę sprawdzić, czy wywołanie callbacka (lub event)
ma odpowiednie parametry, czy są na odpowiednich pozycjach, czy obiekty mają
odpowiednie klucze i wartości itp.

1 Design Testów Frontendowych


Wszystko spoko - ale żeby cała funkcjonalność biznesowa działała, komponent
z formularzem trzeba opakować w komponent, który będzie wiedział, co to kliknięcie
ma dalej oznaczać. Tzn. czy zapisujemy coś in-memory - czy wysyłamy żądanie HTTP,
💅
czy jeszcze coś innego. Dodatkowo, jeśli nasz system wygląda „ładnie” , to formularz
z buttonem prędzej będzie składał się z 10 różnych, mniejszych komponentów - niż 1
kolosa na 500 linijek. I gdybyśmy, zgodnie z ideą testowania jednostkowego, testowali
jedynie dany unit, to... wprawdzie testowany obszar jest mały, łatwo test zrozumieć,
i pomylić się ciężko...

Ale - wróćmy do meritum - żeby przetestować, czy CAŁOŚĆ działa, to i tak trzeba
dodać jeszcze testy integracyjne. Bo nasze kliknięcie np. przechodzi w wywołanie
callbacka, callback dispatchuje thunka/sagę/observabla/..., ten zmienia się w akcję do
reduxa - i gdzieś hen na końcu jest dopiero strzał HTTP. Tak jak poniżej:

Komponenty w aplikacji opartej o reduxa możemy testować albo z uwzględnieniem


całego reduxa - albo bez niego (mockując go). Albo w teście bierze udział większy - albo
mniejszy obszar. Podobnie w aplikacjach opartych o konteksty (własne lub 3rd party):

2 Design Testów Frontendowych


Albo sam komponent (unit test) - albo komponent wraz hookiem, ale z fejkowym
kontekstem - albo komponent plus jego konteksty (czyli na poziomie reacta - wszystko
prawdziwe) - ale mockujemy tylko to, co jest już poza aplikacją.

I pytanie - skoro i tak musimy przetestować całą ścieżkę z góry na dół, aby mieć
pewność, że całość bangla: to jak wiele daje nam dodatkowe pokrywanie każdego unita
jego osobnymi testami?

Owszem, test jednostkowy w przypadku regresji powie mi „o tu, tutaj jest błąd, źle
wywołałeś callbacka typie! Patrz, o tu, widzisz?”. Ale po pierwsze: nie powie mi,
która konkretnie funkcjonalność na tej regresji ucierpi. Po drugie: nie powie mi, czy
💸
ktokolwiek w ogóle ucierpi. I po trzecie: każdy test ma swój koszt obsługi . Nie tylko
jego uruchamianie trwa (czas długości trwania testów), ale także - jego utrzymywanie
kosztuje (nasz czas pracy). W efekcie, podejście typu „im więcej testów tym lepiej” to
🤭
- cytując klasyka: ani świento prawda, ani tys prawda - tylko g. prawda . Nie należy
danej funkcjonalności pokrywać testami takimi, śmakimi i owakimi, w nadzieji że będą
„lepiej” przetestowane - bo to zwiększa koszty, ale regresje, które wychwytujemy są
potencjalnie nadal takie same.

Oczywiście wszystko zależy od konkretnego przypadku. Ale jeśli funkcjonalność już


mamy przetestowaną integracyjnie - i rozważamy dodanie testów jednostkowych...
zadajmy sobie pytanie - co nam to da? Czy w dłuższej perspektywie - to jest większa
korzyść, czy większy koszt?

To jest w ogóle uniwersalne pytanie odnoszące się do architektury: CZY WARTO. Może
- jak ktoś jest wzrokowcem, to bardziej się wryje w pamięć - powtórzmy nasze jedno,
„zajebiście ważne pytanie”:

Czy warto?
I w kontekście testowania UI - czy oprócz testów integracyjnych zawsze pisać także
jednostkowe? Zawsze? Z reguły?

Coraz więcej osób, m.in. twórca React Testing Library, Kent Dodds, dochodzi do wniosku,
że niekoniecznie. Kent w ogóle kwestionuje kształt klasycznej piramidy testów -
i w zamian proponuje testing trophy, które na pierwszym miejscu stawia właśnie testy
integracyjne.

Dlaczego? Bo:

y testy integracyjne UI koncentrują się na perspektywie użytkownika, a nie


perspektywie technicznej (jak testy jednostkowe);

y testy integracyjne potrafią pokryć całą funkcjonalność na poziomie UI (np. bez


HTTP - do tego trzeba E2E, które wychodzą poza UI). A testy jednostkowe nie są od
tego;

y testy integracyjne nie obchodzi „jak działa każdy element po drodze”, tylko czy
wszystkie składowe się poprawnie ze sobą integrują. A testy jednostkowe mają
to do siebie, że jeśli zmienia się implementacja unita, to często sypią się jego testy.

3 Design Testów Frontendowych


Pracując na frontendzie, zmieniamy nasze komponenty codziennie. I to jest normalne.
I z założenia testy jednostkowe komponentów są bardziej kruche, niż integracyjne.

W konsekwencji:

y czy należy całkowicie olać testy jednostkowe? A skąd! Absolutnie nie! Chodzi o to,
aby unitów nie stawiać ich na pierwszym miejscu.

y bezpieczeństwo i stabilność systemu opieramy głównie o testy integracyjne.


Stosujemy mocki i naginamy je tak, aby sprawdzić rozmaite przypadki brzegowe. Te
testy jeszcze są szybkie. Ale żeby upewnić się, że system jako całość działa, łącznie
ze wszystkimi elementami backendowymi - finalnie dodajemy E2E. I wartością
dodaną E2E jest już tylko integracja z tym, co jest poza UI, na konkretnym środo-
wisku (REST API, baza, infrastruktura, etc). Z tym, czego nie przetestowaliśmy
na niższych warstwach.

TL;DR; testy jednostkowe nie są złe. Czasem (np. snapshoty reducerów) są super. Sztuka
w dobraniu odpowiednich proporcji.

„ale... ale... obalasz piramidę testów, którą spopularyzował Martin


Fowler ™️ we własnej osobie?” 😳

Czy obalam? Raczej nie. Bardziej kwestionuję. Właśnie o to chodzi - nie daję gotowych
odpowiedzi - tylko stawiam pytania. Skonfrontuj pytania, które stawiamy w tym
tekście, a także praktyki - z własnym projektem! I sam(a) zdecyduj, co będzie miało
najwięcej sensu, bo to Ty znasz swój projekt najlepiej. Staram się kierować - tu znowu
cytat z klasyka:

I’d rather have questions that cannot be answered, than answers


that cannot be questioned.

i każdemu to polecam 😉.

4 Design Testów Frontendowych


Część 2:
Dobre praktyki
Zostawmy temat unit vs integration vs end-to-end na bok - i skupmy się na bardziej
🤓
przyziemnych sprawach . Jak sprawdzić, czy testy, które piszę, są wartościowe?

Każdy test ma spełniać konkretny cel


Czyżbyś zamierzał(a) pisać testy dla jakiegoś kawałka UI, ale nie wiesz, co dokładnie
chcesz przetestować? Nie masz pewności, jaką funkcjonalność chcesz chronić przed
regresją? No, to być może doznasz olśnienia podczas pisania testów... a być może nie
😈 🙂
. Strzelam, że nie bo setup testu będzie wymagał mockowania HTTP, serwisów,
reaktowych kontekstów itp... potem wejdzie temat 30 różnych asercji i wybierania
między nimi... i prędzej czy później pochłonie nas implementacja. A design się zagubi
gdzieś w tym wszystkim. I w efekcie możemy przetestować wielokrotnie te same
przypadki - lub pominąć przypadki, które pozostaną nieprzetestowane. Wniosek? Zanim
zaczniesz pisać testy, najpierw przemyśl, co chcesz testować. I po co. Możesz np. w jest
zastosować: it.todo(‚should display X... after Y...’) aby desin testów był precyzyjny, zrozu-
miały, „review-owalny” przez innych (tzn. jeśli dev nie ma pewności, co testować, to
najpierw cyk! Pull Request z ideą testów - przed ich napisaniem).

DESIGN-FIRST.

Testy powinny inicjalnie failować.


🙂
To jest grube . Wyobraź sobie - patrzysz na test, który wg git blame jest w repo
😐
od 1,5 roku, w niezmienionej formie. I okazuje się, że... ten test nic nie sprawdza . Ale
jak to?! Ano tak: czy komponent dostaje sprawdzanego w teście propsa jako true czy
jako false (czy cokolwiek) - to test i tak przechodzi. Oczekiwalibyśmy, że np. dla true
przejdzie, a dla false się położy...

Z czego to wynika? Możemy się pomylić np. dobierając odpowiednia asercję związaną
z DOMem albo ze sprawdzaniem mockowych funkcji (tj. jest.spy). Nie z głupoty,
i nie z ignorancji - możemy pomylić się tak po prostu... bo każdy się może pomylić - i ja,
i Ty. Możemy nieopatrznie wprowadzić do naszego kodu tzw. FALSE NEGATIVE (lub,
rekomendowałbym inne określenie: FALSE PASS). Czyli test, który nie wybucha,
a powinien. O tym wszystkim będziemy obszernie mówili w Architekturze na Froncie.

Jak się przed tym ochronić? Jak już zmóżdżysz, jaka powinna być struktura testu, jakie
zachowanie powinno być badane itp - to np. tymczasowo odwróć sytuację (przekaż
innego propsa, inną flagę, etc.) - i upewnij się, że Twój test na pewno failuje. Jeśli failuje

5 Design Testów Frontendowych


przy odwróconym inpucie - to super - bo nie tylko wiemy, że przechodzi przy oczeki-
wanym inpucie - ale także nie przechodzi przy inpucie „odwróconym”. Unikamy FALSE
NEGATIVE/FALSE PASS.

Każdy test powinien nieść jakąś wartość.

import { Component } from './component';

it('should be defined', () => {


expect(Component).toBeDefined();
})

Ja to tutaj zostawię bez komentarza... 😶

Testy, które piszemy, są łatwe w zrozumieniu


i proste w utrzymaniu.
🤭
Brzmi trochę ogólnie . No to odwróćmy sytuację... wyobraźmy sobie, że wchodzimy
w projekt, w ktorym coś implementujemy - i nieoczekiwanie kładą się istniejące testy.
Czytamy je - i wyobraźmy sobie pesymistyczne scenariusze:

y z nazw testów nic nie wynika, bo np. ktoś wpisał ComponentX should work
🤬
correctly... Jakby k wa ktokolwiek oczekiwał, że bedzie inaczej...

y mockowane jest tak dużo, że nie wiadomo, co faktycznie bierze udział w teście.
Czyżby testowane były mocki? 😯
y testy są skomplikowane. Trzeba najpierw się zdoktoryzować z setupu testów (np.
bazujemy na rxjs marble tests), aby w ogóle zrozumieć, co się w nich dzieje.

y testy są zależne. Czyżby skipowanie jednego testu powodowało wywalenie się


innego testu, ktory jeszcze przed chwilą przechodził?

y testy są niedeterministyczne. A może - w zalezności od siły wiatru i kursu bitcoina


- test raz przechodzi a raz nie? Np. raz się mieści w timeoucie a raz nie?

y testy sprawdzają wiele rzeczy na raz. Masz takich kilka testów w projekcie, które
co sprint się kładą i trzeba w nich - co rusz - dłubać i aktualizować? Przyczyn
może być wiele - ale skoro test często failuje, to być może ma wiele powodów do
failowania? Innymi słowy - testowanych jest jednocześnie wiele funkcjonalności -
i zmiana którejkolwiek z nich skuktuje wywaleniem się - na okrągło - tego samego
testu?

Jeśli chcesz aby testowanie kojarzyło Ci się z czymś przyjemnym i dającym satysfakcję,
to każdy z ww. punktów zaneguj. A jeśli testy chcesz znienawidzić - Ty albo Twoje
😈
ziomeczki - to zostaw jak jest .

6 Design Testów Frontendowych


Unikaj kruchych testów.
Kruche (ang. brittle) - czyli takie testy, które przy dowolnym dotknięciu implementacji,
🤭
mogą się sypnąć. Znowu brzmi ogólnie? No to tutaj masz check-listę:

y zmiana implementacji nie wpływa na funkcjonalność - ale test failuje? Test jest
😓
zależny od implementacji (i jest kruchy ).

y chodzisz na skróty i inicjalny stan komponentu ustawiasz „programistycznie”


(np. wrapper.setState)? Spoko - test będzie w mikrosekundach szybszy . 🏎
Ale jeśli wszystkie testy są tak pisane, to współczuję zmieniać w komponentach...
🤮
czegokolwiek. Bo prawdopodobieństwo, że któryś test się sypnie, jest duże . I nie
próbuję tu powiedzieć, że nigdy nie należy hackować testów, aby je przyspieszyć.
Na pewno nie warto tego robić bezmyślnie.

y potrzebujesz zmodyfikować fake data na potrzeby jednego testu, ale tych samych
danych używają inne testy i teraz musisz modyfikować 20 miejsc? Testy za dużo
współdzielą (i są kruche).

y używasz jest.mock dla mockowania np. HTTP i robisz podobne ustawienia


w 10+ plikach? (a przecież jest.mock wymaga ustawieenia setupu mocków
per-1-plik-testowy)? Kopiowanie jest fajne, dopóki nie trzeba potem tych klonów
synchronizować między sobą. Alternatywa, jaką poznamy przy okazji testów
integracyjnych Architektury na Froncie opiera się o msw i jest rozwiązaniem typu
plug n’play, opartym o kompozycję.

y XPath? 😏
y getElementById? 😏

Testuj z perspektywy użytkownika, a nie perspek-


tywy technicznej.
Hm, to może zacznijmy od tego, czym są obie perspektywy.

Perspektywa techniczna - implementacja, kod. Wiem, że jest Component. Wiem, że ma


swoje propsy, metody. I w efekcie moimi testami sterują różne propsy... albo koncentruję
się na wywoływaniu w teście metod komponentu ręcznie...

Albo wiem, że komponent pod spodem ma 2 IFy, nad którymi trochę móżdżyłem... i które
sprawiają że ten-trudny-kawałek-komponentu działa poprawnie. I to mnie tyle koszto-
wało glukozy, żeby te IFy poprawnie napisać, że w sumie tylko to się liczy w testach.
Przecież nic innego nie było trudne do napisania.

Sęk w tym, że jeśli fokusuję się na technikaliach i - testując - świadomie myślę o imple-
mentacji, to moje testy odzwierciedlą tą implementację. Bo SUT (system under test)
traktuję jako white box, znam go od środka. I - kiedy ta się zmieni - testy również będzie
trzeba zmienić.

7 Design Testów Frontendowych


Mogę, dla odmiany, najpierw napisać testy, nie mając implementacji (TDD - co z wielu
powodów może być trudne, skoro celujemy w testy integracyjne UI). Lub udać, że tej
implementacji nie znam - i traktować komponent jako black-box. Przyjąć perspektywę
użytkownika, wejść w jej/jego buty. I wchodzić w interakcję z komponentem tak, jak
robiłby to nasz user. I zapomnieć o tych IFach - bo być może one są dla użytkownika
trzeciorzędne i niepotrzebnie odwracają naszą uwagę od ważniejszych rzeczy, które
trzeba przetestować.

Perspektywa użytkownika to to, do czego sam ma dostęp. User nie widzi propsów ani
😏
metod - a tym bardziej ręcznie ich nie wywołuje . User widzi tekst, widzi buttony
i widgety, może je kliknąć, może coś wpisać z klawiatury. To jest nasze publiczne API.
Jeśli nasz test bazuje jedynie na interakcji użytkownika i w ten sposób detereminuje
stan komponentu - to możemy potem przewrócić do góry nogami implementację 3 razy
(tylko implementację, nie funkcjonalność) - to testu ruszać nie trzeba.

Owszem, komponent trzeba jakoś stworzyć - a to może wymagać podania i propsów


i ukrytych zależności (konteksty, reduxy, dzikie węże...). Ale od tego momentu - tylko
interakcje użytkownika.

Jeśli piszemy nasze testy integracyjne z perspektywy użytkownika, to opieramy je o to,


co user widzi, i czego dotyka. Dzięki temu ograniczamy publiczne API (czyli to, co zmienić
się nie może, aby testy nie wybuchły) tylko do warstwy wizualnej. A nie szczegółów
implementacyjnych. Dopóki input ma swoją „labelkę”, lub - w ostateczności - swoje
data-testid - to możemy implementację refaktorować - ale testów to nie wysadzi.

Użytkownikowi gwarantujemy te same funkcjonalności - ale dzięki temu, że testy


piszemy w nieco innym stylu, redukujemy koszty przyszłych zmian.

To tyle 🙃. Na koniec podsumuję, ponownie cytując klasyka:

Write tests. Not too many. Mostly integration.

Masz pytania? Na kilka najciekawszych pytań postaram się odpowiedzieć!

8 Design Testów Frontendowych


Po więcej naszych materiałów
o frontendzie wpadaj na:
https://architekturanafroncie.pl

You might also like