Piramida testów – czy to jeszcze ma sens?

W poprzednim artykule opowiedziałem o piramidzie testów. Model ten jest użytecznym uproszczeniem i powinieneś się z nim zapoznać, jeśli jeszcze nie miałeś okazji. 

Jednak wraz z doświadczeniem, zauważamy coraz więcej niuansów i odstępstw od wyuczonych reguł. Tak samo jest z testowaniem. Piramida testów jest użyteczna, jednak niekiedy istnieje konieczność zmodyfikowania tego podejścia.

Pojęcie piramidy testów powstało już kilkanaście lat temu, gdy popularna stawała się metodologia TDD. To, że jeszcze o niej pamiętamy, świadczy o tym, że była ona użytecznym drogowskazem dla całego pokolenia programistów. Świat programowania ewoluuje jednak w wariackim tempie. Warto się zastanowić, co się zmieniło przez ten czas w sztuce programowania i czy to nie wymaga od nas zmiany spojrzenia na ten temat.

Testy są tańsze

Jak mogłeś przeczytać w poprzednim artykule, jednym z głównych problemów z testami wyższego poziomu (integracyjnymi i E2E) jest czas wykonywania. Wymagają one często uruchomienia całej aplikacji oraz otaczającej jej infrastruktury – bazy danych, kolejek, etc. Niekiedy nie da się (lub nie opłaca) tego zrobić automatycznie i trzeba przygotować takie środowisko testowe ręcznie.

Choć nadal jest to problem, to już nie tak wielki, jak kilkanaście lat temu. Prawo Moore’a mówi, że moc komputerów podwaja się co 24 miesiące. Nawet jeśli ten rozwój nie jest już tak szybki jak kiedyś, kilkanaście lat to okres, w którym komputery stały się dużo potężniejsze. Do tego takie technologie jak Docker i Test Containers ułatwiły przygotowywanie infrastruktury pod wykonanie testów. Także same frameworki testowe rozwinęły się i testy integracyjne stały się łatwiejsze w pisaniu i wykonaniu, szybsze i bardziej niezawodne. 

Frameworki rządzą

Dziś mało kto wyobraża sobie tworzenie kontrolerów HTTP lub dostępu do bazy danych tak, jak robiło się to kilkanaście lat temu. Ilość kodu do napisania jest coraz mniejsza. Duża część jest generowana przez framework na bazie adnotacji lub w inny sposób. Przyniosło to ulgę wielu programistom zmęczonym pisaniu niepotrzebnego, nadmiarowego i powtarzalnego kodu. 

Jednak taki kod okazuje się często niemożliwy do przetestowania jednostkowo. W Spring Data możemy zdefiniować repozytorium przez stworzenie interfejsu. To, jak działa aplikacja, nie bierze się z kodu, ale konwencji, którą stosuje framework. Nie da się tu wymyślić testu czysto jednostkowego, który byłby użyteczny. Jedyne sensowne podejście, to test integracyjny.

Nie jest to zjawisko nowe, frameworki towarzyszą nam od lat. Jednak ten trend się rozwija i coraz więcej rzeczy robi się za ich pomocą. 

100% code coverage wyszło z mody

Był to trend w początkach TDD, jednak z czasem wiele osób zorientowało się, że pełne pokrycie testami często jest sztuką dla sztuki. Osobiście miałem okazję pracować z osobami, którym testowanie jednostkowe „weszło za mocno”. Efekt? Kod zabetonowany testami, które w praktyce nic nie sprawdzały.

Testy getterów i setterów, byle tylko dobić do wymaganego przez build poziomu? Brzmi jak świetny pomysł! Jedyne co robi klasa to przekazanie wartości z parametru do kolejnego obiektu? To nic, piszemy test. A że przy okazji takie testy nic nam nie mówią o tym, czy kod robi to, do czego jest napisany? To nic, metryki się przecież zgadzają.

Co więc z tymi unit testami?

Nie chcę przekonywać do porzucenia idei testów jednostkowych. Ba, prawdopodobnie to one wciąż powinny być podstawą kontroli jakości Twojej aplikacji. Jak więc sprawić, by wciąż pełniły one swoją rolę, a jednocześnie nie były sztuką dla sztuki i nie prowadziły do zabetonowania aplikacji? Jak wybrać, jakiego rodzaju test w danym miejscu napisać?

Chciałbym przekonać Cię do traktowania piramidy testów w mniej dogmatyczny sposób. To że piszesz nowy kod, nie musi oznaczać, że 80% testów, jakie napiszesz, to będą testy jednostkowe. Skąd więc to wiedzieć?

Moduł płytki czy głęboki?

Chciałbym zapoznać Cię z ideą podziału modułów w aplikacji na głębokie i płytkie. Jest to ciekawa koncepcja, która przydaje się przy dokonywaniu decyzji architektonicznych, jak na przykład właśnie wybór strategii testowania.

Moduły głębokie 

Moduł głęboki to taki, w którym duża złożoność jest ukryta za prostym interfejsem. Patrząc z zewnątrz, moduł może wydawać się prosty, jednak logika, która kryje się pod spodem, jest rozbudowana. 

Przykładem takiego modułu mógłby być kod, który pozwala na wyświetlenie, jaką trasą możemy najbardziej optymalnie przejechać z punktu A do punktu B. Efekt końcowy może być prosty, bo może to być jedynie lista miast, przez które musimy przejechać. Jednak pod spodem dzieje się sporo, od pobrania danych z jakiegoś rodzaju bazy danych przez zaawansowane algorytmy wyznaczania ścieżek do zaprezentowania tego w formie przyjaznej do użytkownika. 

Ponieważ interfejs jest prosty,  a wnętrze skomplikowane, trudno jest zrozumieć, a także opisać działanie tego modułu, patrząc jedynie z zewnątrz. To jest cecha testów E2E-owych, że testują głównie interfejs modułu lub aplikacji. Dlatego tego rodzaju testy nie będą tu użyteczne. Pewnie warto napisać kilka testów integracyjnych, żeby upewnić się, że całość kodu działa tak, jak tego chcieliśmy. Jednak do testowania logiki użyjemy testów jednostkowych. Pozwalają nam one „wkopać” się do wnętrza modułu i testować jego pojedyncze aspekty w izolacji.

W modułach głębokich prawdopodobnie tradycyjna piramida testów będzie dobrym rozwiązaniem. W bardzo głębokich modułach możliwe nawet, że testów E2E będzie kilka, a jednostkowych setki. Możliwe też na przykład, że testy integracyjne w ogóle nie będą potrzebne, ponieważ moduł będzie zawierał jedynie czystą logikę biznesową, bez żadnych integracji, więc piramida może być nawet bardziej „agresywna”, niż zwykle, z większą przewagą unit testów

Moduły płytkie

Z kolei moduł płytki jest odwrotnością tego pierwszego. Pod interfejsem nie kryje się złożona logika, a zwykle patrząc na sam interfejs można dość łatwo wyobrazić sobie, jaka jest jego implementacja. 

Przykładem może być moduł, który w odpowiedzi na zapytanie HTTP odczytuje dane z bazy danych i pakuje je w odpowiedź bez nawet szczególnej konwersji na jakąś inną strukturę. W bazie danych jest tabelka z dziesięcioma kolumnami, w odpowiedzi HTTP jest JSON z obiektem z dziesięcioma polami. Typowy CRUD (Create-Read-Update-Delete). Wiadomo, co się dzieje pod spodem i nie są to szczególnie złożone algorytmy, sam kod też jest dość przewidywalny. W tym przypadku raczej będziemy mieli kontroler i repozytorium. Być może znajdzie się jakiś serwis, ale nawet jeśli, to nie robi on wiele więcej, niż wywołanie repozytorium i zwrócenie danych.

W takiej sytuacji pisanie testów jednostkowych może być wspominaną wcześniej sztuką dla sztuki. Kontroler i repozytorium przetestujemy raczej integracyjnie, zaś test jednostkowy serwisy (o ile w ogóle taki będzie tu istniał) nie powie nam więcej o działaniu modułu, niż dobry test E2E, który sprawdzi, czy całość działa poprawnie i otrzymujemy prawidłową odpowiedź HTTP.

W takiej sytuacji nasza strategia testowania może być bardzo inna, niż klasyczna piramida testów. W skrajnych przypadkach możliwe, że nie napiszemy ani jednego testu jednostkowego, a pozostaniemy jedynie przy integracyjnych i E2E-owych. Prawdę mówiąc, miałem okazję pracować przy projektach, gdzie nie było żadnych unit testów i nie odczuwałem ich braku (choć w tym podejściu też można przesadzić). Możemy więc skończyć z czymś, co będzie raczej odwróconą piramidą testowania, gdzie im wyższy poziom testów, tym jest ich więcej.

Jeśli moduł jest płytki, to struktura testów może przybrać nawet kształt odwróconej piramidy

Prawdziwy świat

Pewnie moduły, które będziemy pisać, nie będą ani tak głębokie, jak podany przykład aplikacji do wyznaczania trasy, ani tak proste jak wspomniany prosty CRUD. Raczej coś pomiędzy. Dlatego też nasza strategia testowania nie będzie idealną tradycyjną piramidą, ani też odwróconą piramidą. 

Można sobie na przykład wyobrazić sytuację, gdzie mamy dość złożoną aplikację, ale jej złożoność polega na integracji z wieloma zewnętrznymi systemami. Łączymy się z jakąś bazą danych, potem z kolejką, wysyłamy zapytanie do zewnętrznego API REST-owego i dopiero otrzymujemy wynik. W takiej sytuacji możemy otrzymać system, gdzie najwięcej będzie testów integracyjnych, a tych jednostkowych i E2E-owych będzie stosunkowo niewiele.

Tego typu struktura testów może mieć sens, jeśli Twój moduł integruje się bardzo mocno z zewnętrznymi systemami

Co z tym wszystkim zrobić?

To, co chciałbym, żebyś wyciągnął z tego artykułu, to idea, że niekoniecznie należy się trzymać kurczowo jednej strategii testowania. Raczej przyglądaj się modułom, które piszesz i decyduj, jakie testy najlepiej zapewnią Ci przekonanie, że Twój projekt jest dobrze przetestowany. Do tego przyda Ci się wspomniany wcześniej koncept modułów głębokich i płytkich.

Z tego wszystkiego można też wyciągnąć wniosek, z którym się zresztą spotykałem, że jakiekolwiek strategie testowania nie mają za bardzo sensu. I tak decyzje o użycie konkretnego typu testu podejmujemy w zależności od tego, jaki typ kodu właśnie piszemy. Czemu by po prostu nie oceniać tego na bieżąco i nie pisać testów, jakie w danej sytuacji uznamy za stosowne? Po co myśleć o tym, jak procentowo rozkłada się udział różnych testów w naszej aplikacji i w jaki kształt to się układa?

Nie wydaje mi się jednak, żeby całkowite porzucenie tej idei było najlepszym pomysłem. Spoglądanie na strategie testowania pozwala nam nie stracić z oczu dużego obrazka. Wciąż (choćby przybliżone) porównanie, ile mamy testów na którym poziomie, jest przydatną metryką, która pozwala zauważyć, czy idziemy w dobrą stronę. Może zwrócić uwagę na przykład, że trochę za bardzo postawiliśmy na testy E2E i warto kilka z nich przepisać na unit testy. Taka sytuacja zdarzyła mi się zresztą całkiem niedawno.

Więc nie porzucajmy całkiem piramidy testowania. Jednak zdawajmy sobie sprawę, że tak jak w każdej dziedzinie, dawne wzorce zmieniają się. Pracując w takiej branży jak IT, ignorowanie tego nie jest dobrym pomysłem.