Blog

31 października 2014 Wojciech Zawistowski

3 typy testów, które musisz zrozumieć, by robić efektywne TDD

Tagi:

3 typy testów, które musisz zrozumieć, by robić efektywne TDD

Bałagan w testach!

Istnieje zatrzęsienie różnych rodzajów automatycznych testów. Jeżeli spojrzymy na wpis na Wikipedii dotyczący testowania, znajdziemy tam wiele wyspecjalizowanych typów testów: sanity, smoke, usability, security, conformance itd.

Podobnie, tak zwaną “piramidę testów”, spopularyzowaną przez Mike’a Cohn’a w jego książce Succeeding with Agile, też można spotkać w różnych wariantach, z różną ilością i różnie nazwanymi warstwami.

Piramida testów Martina Fowlera Piramida testów Alistera Scotta Piramida testów Itamara Hassina

Aby wszystko było jeszcze bardziej rozmyte, wiele różnych rodzajów testów działa na tym samym poziomie. Np. testy funkcjonalne, akceptacyjne, end-to-end, systemowe i serwisowe, wszystkie są testami wysokiego poziomu, traktują aplikację jako “czarną skrzynkę”, obejmują wszystkie warstwy aplikacji i nie są wyizolowane. Różnica leży w niuansach: Czy test przechodzi przez interfejs HTML, czy jedynie przez REST-owe API? Czy przyjmuje punkt widzenia użytkownika końcowego aplikacji, czy bardziej funkcjonalny? Czy weryfikuje pełny, dłuższy workflow, czy pojedyncze, bardziej atomowe akcje użytkownika? Różnice są często minimalne i niejasne a terminologia nagminnie mylona i mieszana ze sobą.

A to jeszcze nie koniec historii! Niektóre terminy mają wiele znaczeń, np. część osób utożsamia testy integracyjne z testami end-to-end (z integracją wszystkich części systemu) podczas gdy dla innych oznaczają one integrację jedynie z pojedynczym zasobem lub modułem, niemalże na poziomie testów jednostkowych.

Nie daj się przytłoczyć

Czy musimy biegle poruszać się po tej całej dżungli, by móc efektywnie używać TDD do budowy naszej aplikacji?

Na szczęście nie.

Możemy wyróżnić 3 główne zakresy testów:

  • testowanie osobno wszystkich niskopoziomowych komponentów aplikacji
  • testowanie aplikacji jako nierozdzielnej całości, z “zewnętrznego” punktu widzenia
  • testowanie punktów połączeń pomiędzy aplikacją i zewnętrznymi zasobami

Istnieje wiele dopuszczalnych nomenklatur, ale na potrzeby dalszej dyskusji nazwijmy te trzy zakresy, odpowiednio:

  • testami jednostkowymi
  • testami end-to-end
  • testami integracyjnymi

Nie zamierzam zaprzeczać, że orientacja we wszystkich subtelnych niuansach testowej nomenklatury jest użyteczna. Jednak zrozumienie charakterystyki powyższych 3 typów testów, i tego jak się dopełniają, to co najmniej 80% drogi ku skutecznemu procesowi TDD.

Jednak zanim zagłębimy się w detale, odpowiedzmy sobie krótko na jedno pytanie:

Po co piszemy testy?

Istnieją 3 główne powody. Chcemy, by nasza aplikacja:

  • była tak bezbłędna, jak to możliwe
  • była łatwa do modyfikacji
  • spełniała oczekiwania użytkownika

Zobaczmy, jaki udział 3 wymienione powyżej typy testów mają w spełnieniu tych celów i czym się w efekcie charakteryzują:

Testy jednostkowe

Testy jednostkowe zaspokajają pierwsze dwie potrzeby: eliminację błędów i zapewnienie łatwości modyfikacji aplikacji.

Jakie cechy testów wynikają z tych dwóch celów?

By dać nam pewność, że aplikacja jest wolna od błędów, testy muszą mieć tak wysokie pokrycie, jak to możliwe. Musimy sprawdzić wszystkie podstawowe i alternatywne ścieżki wykonania, przypadki brzegowe, reakcję na błędy zewnętrzne, niepoprawne dane wejściowe itd.

Z kolei aby móc łatwo modyfikować aplikację, konieczne są trzy warunki:

Zmiany, których dokonujemy, muszą być enkapsulowane, wolne od jakichkolwiek efektów ubocznych, rozchodzących się falami przez cały system.

Testy muszą być łatwe do debugowania i dawać precyzyjny feedback, tak byśmy w przypadku popełnienia błędu natychmiast wiedzieli, który fragment kodu się popsuł i dlaczego.

Testy muszą być szybkie. Jeżeli nasza suita testowa bedzie za wolna, nie będziemy jej puszczać często, tracąc tym samym zdolność pracy małymi kroczkami, co sprawi, że modyfikacje będą trudniejsze.

By zaspokoić wszystkie te potrzeby, nasze testy muszą być wyizolowane.

Bez izolacji, ciężko osiągnąć wysokie pokrycie. Gdy “patrzymy” na aplikację wyłącznie z zewnątrz, liczba i złożoność testów rosną wykładniczo wraz z ilością współpracujących ze sobą komponentów. Przyjmijmy, że mamy 3 komponenty, każdy z 3 przypadkami brzegowymi do sprawdzenia. Kiedy testujemy je oddzielnie, otrzymujemy 3 + 3 + 3 = 9 testów. Ale gdy testujemy je razem, musimy zweryfikować wszystkie możliwe kombinacje – w efekcie otrzymujemy nagle eksplozję 3 * 3 * 3 = 27 możliwych przypadków do pokrycia. W jakiejkolwiek nietrywialnej aplikacji, błyskawicznie staje się to nie do ogarnięcia.

Taki design aplikacji, który umożliwia testowanie wszystkich jej części oddzielnie, zazwyczaj pozwala również na modyfikowanie ich oddzielnie. Tak więc, dążenie do izolacji testów jest najprostszą heurystyką projektowania systemu pozbawionego efektów ubocznych, łatwego do modyfikacji.

Kolejną rzeczą, jaką dają nam wyizolowane testy, jest precyzyjny feedback. Gdy wszystkie drobne części składowe systemu są testowane niezależnie od siebie, testy są w stanie natychmiast wskazać dokładne położenie błędu.

I ostatnia, równie istotna kwestia: izolacja jest podstawowym narzędziem zapewniającym szybkość testów. Eliminacja zależności – zwłaszcza wolnych, jak np. bazy danych czy system plików – może przyspieszyć suitę testową nawet o kilka rzędów wielkości.

Implikacją pełnej izolacji testów jest też to, że musimy przyjąć podejście białej skrzynki (założyć że znamy architekturę i interfejsy niskopoziomowych komponentów) i że musimy testować na poziomie kodu.

Testy end-to-end

Testy end-to-end zaspokajają ostatnie z naszych 3 wymagań: zapewniają, że aplikacja spełnia oczekiwania użytkownika.

Upewnienie się, że poszczególne komponenty aplikacji są poprawne, nie jest wystarczające. Nie daje to automatycznie gwarancji, że ich suma również będzie poprawna.

Po pierwsze, komponenty mogą być ze sobą niepoprawnie spięte, przez co aplikacja może w ogóle nie działać.

Po drugie, nawet jeśli aplikacja wydaje się działać, może nie spełniać wszystkich oczekiwań użytkownika. Poszczególne kroki procesu mogą być poprawne, ale kompletny, dłuższy workflow może zawierać błędy na wyższym, koncepcyjnym poziomie.

By wychwycić problemy tego typu, potrzebujemy testów, które są nie wyizolowane – które traktują aplikację jako nierozdzielną całość.

Co rozumiemy przez “całość”, jest nieco rozmytą kwestią. Nowoczesna aplikacja webowa składa się zazwyczaj z 2 części: REST-owego backendowego API oraz Javascript-owego klienta typu “Single Page App”. Dodatkowo, łączy się ona przeważnie z wieloma web serwisami (naszymi własnymi lub zewnętrznymi), bazami danych i systemami kolejkowymi (lokalnymi lub tzw. “backend-as-a-service“) itp.

Musimy ustalić granice, które elementy tego stosu traktujemy jako integralną część naszej aplikacji, a które jako niezależne zewnętrzne systemy. Te granice mogą mieć wpływ na architekturę naszych testów (np. może od nich zależeć, czy będziemy testować poprzez wywołania HTTP czy poprzez automatyzację przeglądarki). Jednak niezależnie od tego, jak je ustanowimy, podstawowe cechy naszych testów end-to-end będą podobne:

Po pierwsze, musimy traktować naszą aplikację jako czarną skrzynkę. Musimy testować na poziomie interfejsu użytkownika końcowego, bez znajomości wnętrzności aplikacji. I nie powinniśmy robić żadnych skrótów: nie powinniśmy mockować żadnych fragmentów kodu, omijać jakichkolwiek warstw, wrzucać fikstur bezpośrednio do bazy danych itp.

Z takiego podejścia wynikają dalsze implikacje:

Ponieważ zawsze, w każdym teście, przechodzimy przez wszystkie warstwy aplikacji (włączając w to “ciężkie” zasoby, takie jak bazy danych itp.), testy end-to-end są wolne.

Są one również dużo bardziej złożone – jak omówiliśmy w sekcji dotyczącej testów jednostkowych, weryfikacja wszystkich przypadków brzegowych wszystkich współpracujących ze sobą komponentów, w sposób wykładniczy zwiększa ilość koniecznych testów.

Kolejną implikacją testowania wszystkich warstw aplikacji na raz jest to, że takie testy dają mało precyzyjny feedback i są w związku z tym trudne do debugowania (wskazują jedynie, która wysokopoziomowa funkcjonalność nie działa zgodnie z oczekiwaniami, ale nie mówią nam, który fragment kodu jest niepoprawny).

Dlatego, powinniśmy pisać jedynie tyle testów end-to-end, ile jest minimalnie niezbędne. Zazwyczaj pokrywamy jedynie główne poprawne scenariusze plus kilka najistotniejszych scenariuszy alternatywnych i błędnych.

Testy integracyjne

Zaspokoiliśmy już wszystkie nasze potrzeby związane z testami: testy jednostkowe zapewniają niskopoziomową poprawność i łatwość modyfikacji a testy end-to-end gwarantują spełnienie oczekiwań użytkownika końcowego. Po co nam więc kolejny rodzaj testów?

Testy integracyjne odgrywają pomocniczą rolę. To wyspecjalizowany typ testów, koncentrujący się na punktach połączeń z zasobami zbyt “ciężkimi” dla testów jednostkowych, ale mającymi zbyt wiele przypadków brzegowych by móc być w pełni pokryte przez testy end-to-end.

Czym się one charakteryzują?

Podczas gdy testy jednostkowe i end-to-end są swoimi dokładnymi przeciwieństwami, testy integracyjne mieszczą się gdzieś pośrodku, łącząc w sobie cechy obu ze stron:

Są one niemal równie wyizolowane jak testy jednostkowe (powinny sprawdzać jedynie pojedynczy punkt integracji na raz), są więc podobnie łatwe do debugowania i zapewniają precyzyjny feedback.

Wysoki poziom izolacji wymusza także podejście białej skrzynki oraz pisanie testów na poziomie kodu – znów tak samo jak w przypadku testów jednostkowych.

Z drugiej strony, testy integracyjne sprawdzają “ciężkie” zależności, więc mimo że nie przechodzą przez tak wiele warstw jak testy end-to-end, i tak są niemal równie wolne. W związku z tym, podobnie jak w przypadku testów end-to-end, powinniśmy tworzyć jedynie tyle testów integracyjnych, ile jest minimalnie niezbędne.

Nasuwa się tu pytanie: Ile testów integracyjnych jest rzeczywiście niezbędne?

Naiwna odpowiedź mogłaby brzmieć: tyle, ile mamy “ciężkich” zależności. Nie jest to jednak tak proste. Niektóre zależności mogą wymagać sprawdzenia kilku różnych stanów (np. dla wywołania web serwisu: nie pusty wynik, pusty wynik, błąd zapytania i timeout). Z drugiej strony, niektóre z tych przypadków mogą już być pokryte przez testy end-to-end. W przypadku zależności zewnętrznej, ilość testów zależy też od Twojej znajomości jej API i Twojego poziomu zaufania – możliwe, że zadowolisz się sprawdzeniem, czy zależność jest poprawnie podpięta, ale możesz też czuć potrzebę bardziej skrupulatnej weryfikacji. Znalezienie złotego środka to bardzo szeroki temat, wykraczający poza zakres tego posta.

Uproszczona piramida testów

Kiedy uogólnimy testy do 3 typów omówionych powyżej, piramida testów staje się bardzo prosta:

Moja piramida testów

Składa się ona jedynie z dwóch poziomów: dużej ilości testów jednosktowych u podstawy i niewielkiej ilości testów end-to-end na szczycie.

Te 2 typy testów wzajemnie się dopełniają. Testy jednostkowe weryfikują wszystkie niskopoziomowe detale, testy end-to-end zapewniają spojrzenie wysokopoziomowe. To nie jest wybór stylistyczny i żaden z tych dwóch rodzajów testów nie jest “lepszy”. Obie warstwy służą odmiennym celom i muszą być używane równocześnie, we właściwych proporcjach – w przeciwnym wypadku Twojej suicie testowej nie będzie można w pełni zaufać.

Testy integracyjne są celowo umieszczone na zewnątrz piramidy i oznaczone linią przerywaną. Zobrazowanie ich jako środkowego poziomu piramidy, pomiędzy testami jednostkowymi i end-to-end, było by moim zdaniem mylące.

Po pierwsze, sugerowało by to, że testów integracyjnych jest zawsze więcej niż testów end-to-end. To jest oczywiście nieprawdziwe. Ilość testów integracyjnych zależy od ilości i złożoności zależności aplikacji, i jest jak najbardziej możliwe (i bardzo często się tak zdarza) że testów integracyjnych będzie mniej niż testów end-to-end. Bardzo prosta aplikacja może nawet w ogóle nie potrzebować testów integracyjnych.

Po drugie, umieszczenie ich pomiędzy testami jednostkowymi i end-to-end sugerowało by, że one także w jakiś sposób dopełniają się wzajemnie z tymi typami testów. Jednak testy integracyjne nie pełnią takiej funkcji – odgrywają one jedynie rolę pomocniczą i nie są częścią ścisłej synergii, jaka występuje pomiędzy testami jednostkowymi i end-to-end.

Taka 2 poziomowa piramida testów może wydawać się nadmiernie uproszczona (zwłaszcza z putnktu widzenia specjalisty QA), ale jeżeli dogłębnie ją zrozumiesz, jest to jak najbardziej wystarczający model, by kierować się nim podczas efektywnego procesu TDD.


Co sądzisz o takiej piramidzie testów? Masz jakieś inne spostrzeżenia na temat 3 typów testów opisanych w tym poście? Podziel się swoim zdaniem w komentarzach poniżej!

Zobacz na blogu

09.09.2022
Marcin Jahn
It’s Not Just HTTP It’s Not Just HTTP

In today’s world of cloud-based solutions, distributed systems, and microservices-based architectures, network communication is a...

23.08.2022
Adam Mrowiec
Konferencja IPC 2022 Berlin Konferencja IPC 2022 Berlin

Pandemia wreszcie się kończy, dlatego w tym roku postanowiliśmy wrócić do naszych wyjazdów na konferencje....