Kategoria: Testy

  • Piramida testów

    Gdy młody programista zaczyna swoją przygodę z testowaniem, często napotyka się po raz pierwszy na pojęcie piramidy testów. Stało się ono bardzo popularne kilka lat temu, wraz z tym, jak testy automatyczne „weszły pod strzechy” i stały się częścią codziennej rzeczywistości programistów.

    Warto sobie zdawać sprawę, że to podejście nie zawsze musi być optymalne i istnieją odstępstwa od tego modelu. Piramida testów jest przydatnym uproszczeniem i jeśli dopiero zaczynasz rozumieć, o co chodzi w testowaniu, to naprawdę, NAPRAWDĘ, polecam, abyś zrozumiał to podejście i stosował je w praktyce. W wielu przypadkach jest ono dobre, a w najgorszym razie nie jest dramatycznie złe.

    Zacznijmy od wyjaśnienia, jak rozumiemy poszczególne poziomy testów.

    Testy jednostkowe

    Pierwszym rodzajem testów są testy jednostkowe. Dla niektórych to synonim zautomatyzowanych testów, jednak warto wiedzieć, że to jedynie jeden z ich rodzajów. Ich cechą jest to, że testujemy w nich jedynie… cóż, jednostkę. Zawsze tu pojawia się dyskusja – czym jest jednostka (unit)? Co programista, to opinia, ja nie zamierzam Ci narzucać żadnej.

    Jedynie zaznaczę, że dla mnie unit nie musi być koniecznie jedną klasą. Jeśli mamy zespół kilku klas, które ściśle ze sobą współpracują i nie mają sensu w oderwaniu od siebie, nie widzę sensu, by testować każdą z nich osobno. Takie testy bardziej zaciemniają sytuację i „betonują” kod – sprawiają, że jakikolwiek refactoring jest bolesny, bo trzeba zrobić zmiany w wielu testach, mimo że sama funkcjonalność się nie zmienia.

    Co jednak z pewnością charakteryzuje test jednostkowy to szybkość wykonywania. Aby test wykonywał się szybko, musimy maksymalnie go uprościć. Nie ma mowy o łączeniu się do bazy danych, stawiania serwera HTTP, tworzeniu kontekstu springowego. Testujemy tylko klasę lub kilka klas, w izolacji od całego świata. Dzięki temu, wykonanie takiego testu nie powinno zająć więcej niż kilka milisekund.

    Testy integracyjne

    Drugim poziomem są testy integracyjne. Tu też znajdą się różne definicje, jednak wyznacznikiem jest wyjście poza sam kod. Jeśli testujemy nie tylko nasz kod, ale też framework, którego używamy (np. Spring), jeśli łączymy się z bazą danych lub wysyłamy coś na topic Kafki, to prawdopodobnie mamy już do czynienia z testem integracyjnym.

    Dobrym przykładem mógłby być test repozytorium Spring Data. Mogłoby się wydawać, że przedmiotem testu jest tylko klasa, tak więc test jest jednostkowy, jednak sam kod repozytorium nie ma większego sensu, jeśli nie zostanie odpowiednio zinterpretowany przez Springa. Twój test nie sprawdza tak naprawdę Twojego kodu, tylko czy dobrze zintegrowałeś się z frameworkiem, jakim jest Spring Data. 

    Innym przykładem ze świata Springa mógłby być test kontrolera. Znów, klasa kontrolera sama w sobie nie robi wiele ciekawych rzeczy. Cała magia dzieje się, gdy Spring Web analizuje adnotacje, którymi oznaczyłeś klasę i na tej podstawie tworzy endpointy HTTP.

    Bez testów integracyjnych trudno wyobrazić sobie dziś dobrą aplikację. Jakie są ich wady? Z pewnością szybkość. W przypadku testów springowych test odpala tak naprawdę cały kontekst, co jest kosztowne. Czas wykonania przeciętnego testu integracyjnego może wynosić nawet kilkaset milisekund.

    Testy E2E

    Na górze piramidy zobaczymy testy E2E (end-to-end, obejmujące całą aplikację). W czasach, gdy tworzono pojęcie piramidy testów, tutaj zwykle pojawiały się już testy manualne. Oddelegowana osoba ręcznie przechodziła różne ścieżki w aplikacji w celu wyłapania błędów. Jeśli nawet takie testy były zautomatyzowane, to często wciąż zajmowała się nim osoba spoza zespołu programistów. Nie trzeba chyba mówić, że takie podejście było bardzo kosztowne, ponieważ ludzie zawsze będą drożsi w utrzymaniu, niż kawałek kodu.

    Jednak technologia poszła do przodu i dziś pisanie niezawodnych testów E2E nie jest aż takim wyzwaniem. Często zajmuje się tym sam programista. Wciąż jednak są one dość drogie w utrzymaniu, ponieważ są bardzo wolne. Wymagają często uruchomienia całego środowiska, a niekiedy łączą się z zewnętrznymi systemami, które zawsze będą miały narzut czasowy związany z przesyłem danych.

    Są też mniej stabilne- zmiana każdej niewielkiej części kodu może skutkować w zmianie wyniku testu. Z tego wynika jeszcze jeden problem. Ponieważ test obejmuje dużą ilość kodu, trudniejsze jest dostrzeżenie przyczyny, jeśli wykonanie go się nie powiedzie. Test jednostkowy zwróci negatywny wynik jeśli dana klasa jest źle zaimplementowana. Jeśli nie powiedzie się test E2E – musimy dopiero rozpocząć poszukiwanie źródła błędu.

    Mimo to, testy E2E są niezastąpione do upewnienia się, że całość aplikacji działa tak, jak sobie to wyobraziliśmy. Pełnią więc często rolę testów akceptacyjnych.

    Piramida testów

    Teraz, gdy już wiemy, co kryje się pod poszczególnymi kategoriami testów, możemy wyjaśnić, co kryje pod swoją nazwą piramida testów. Kilkukrotnie wspominałem o szybkości działania i koszcie utrzymania poszczególnych testów. Najszybsze są testy jednostkowe, najwolniejsze są testy E2E. To wymusza już na nas pewną strategię testowania. Jeśli napiszemy mnóstwo testów integracyjnych i E2E, taki zestaw testów będzie się wykonywał godzinami (uwierz mi, widziałem takie systemy). Takim ograniczeniom nie podlegają testy jednostkowe. Setki lub tysiące takich testów to wciąż kwestia kilku sekund. Dzięki temu programista może bez problemu puszczać takie testy co kilka minut bez negatywnego wpływu na swoją efektywność (i stan psychiczny).

    Nazwa piramidy bierze się od liczby testów, które chcemy mieć na poszczególnych poziomach. Oto, jak można taką strategię testów zwizualizować.

    Staramy się więc, aby zdecydowaną większość naszych testów stanowiły testy jednostkowe. Są one tanie w pisaniu i utrzymaniu, są szybkie, więc możemy puszczać ich setki lub tysiące naraz, utrzymując krótki czas wykonania oraz produkujemy je niejako „przy okazji” developmentu (przynajmniej tak powinno być przy użyciu podejścia TDD).

    To były podstawy wiedzy o strategii testowania. Jeśli chcesz dowiedzieć się więcej, przeczytaj też mój kolejny artykuł:

    Piramida testów na głowie

  • Given-When-Then to ściema? O BDD w testach

    Pisanie testów jednostkowych w manierze TDD już od dawna było dla mnie czymś oczywistym. Tego dnia mogę powiedzieć, że byłem naprawdę zadowolony z kodu oraz z testów, które napisałem.

    git commit, git push i wystawiam pull requesta. Czekam na review od kolegi.

    Dostaję komentarz:

    Wszystko spoko, fajnie, testy mi się podobają, tylko dopisz komentarze given when then.

    Pierwsza reakcja: „A no tak, rzeczywiście, zapomniałem”. Pisanie testów w takim stylu było u nas niepisaną regułą. Już miałem odpisać, że zaraz poprawię, jednak w tym momencie zatrzymałem się… i zapytałem sam siebie: „Zaraz, ale właściwie… to po co?”

    Mimo popularności, jest kilka rzeczy, o których zwolennicy tej metody nie wspominają. O nich właśnie jest ten artykuł.

    Właśnie, po co to Given When Then?

    Given-When-Then to podejście do pisania testów wywodzący się z ruchu Behaviour-Driven Development. Bardziej konkretnie związane jest to z ideą Specyfikacji przez przykład. Zapoznanie się z linkami może pomóc zrozumieć to podejście, jednak co musisz wiedzieć, to sprowadza się to do zapisywania testów bardziej „ludzkim” językiem – takim, aby był zrozumiały też dla osób nietechnicznych.

    Są narzędzia, które wspierają pisanie testów (z założenia głównie akceptacyjnych) w takiej manierze. Wiele osób kojarzy pewnie takie narzędzia jak Cucumber lub JBehave. Taki sposób pisania testów wyciekł również do naszych codziennych testów jednostkowych i najczęściej realizuje się go za pomocą komentarzy sygnalizujących każdą sekcję.

    Rozważmy prościutki test unitowy:

    @Test
    void shouldRejectOrderGivenNotEnoughItems() {
      //given
      Store store = new Store();
      store.addItems("pencil", 100);
    
      //when
      Order order = store.makeOrder("pencil", 150);
    
      //then
      assertThat(order.getResult()).isFalse();
    }

    Co tu jest nie tak? Na pewno nie jest to BDD! I prawda jest też taka, że konwencja given-when-then już zdążyła się od BDD „odkleić” i żyje teraz własnym życiem. Wiele osób stosuje to trochę bezrefleksyjnie. Wydawałoby się nawet, że traktują tę formułę jako pewnego rodzaju zaklęcie, które ma w magiczny sposób poprawić design ich testów.

    Jest given-when-then, jest approve!

    Potrafi być to niestety podejście zgubne. Fakt, że given when then może wprowadzić nieco przejrzystości, wskazując, które części za co odpowiadają. Potrafi to być jednak zdradliwe.

    Problemy z komentarzami w kodzie

    Pewnego razu miałem wątpliwą przyjemność oglądać kod, gdzie komentarze given when then były poumieszczane w taki sposób, że jedynie zaciemniały obraz sytuacji. Pierwsze co chciałem zrobić, to poprzesuwać kod testowy, aby miało to wszystko sens. Zauważyłem jednak, że nie jest to możliwe przez to, jak powiązany ze sobą cały kod był. Co więc zrobiłem zamiast tego, to ich zwyczajnie te komentarze usunąłem.

    Dlaczego ta historia tak się zakończyła? Odpowiedź podpowiada dość znana zasada czystego kodu:

    Komentarze kłamią!

    Każdy programista świeżo po przeczytaniu książki „Clean code” Uncle Boba

    Nawet nie musi to być zła wola czy też brak staranności lub wiedzy osoby piszącej test. Możliwe, że sposób, w jaki Ty umieścisz te komentarze, będzie zupełnie w porządku. Jednak pamiętaj, że z chwilę wpisania komendy git push kod przestaje być Twoją wyłączną własnością. Od tej pory każdy ma do niego dostęp i niekoniecznie będziesz w pobliżu, gdy ktoś postanowi go zmienić, abyś mógł go powstrzymać. 

    Ktoś może się oburzyć, że przecież nie możemy wziąć odpowiedzialności za przyszłe cudze błędy. Czy na pewno? Jeśli ułatwiamy komuś pomylenie się, część odpowiedzialności spada na nas. Tak samo, jeśli nie zrobiliśmy wszystkiego, co możliwe, by to utrudnić.

    Zgodnie z ideą poka-yoke za pomyłki nie należy winić ludzi, ale procesy. Odpowiedzią jest czynienie tego co robimy błędoodpornego lub, jak kto woli, idiotoodpornego.  Kiedy ktoś próbuje zmienić Twój kod, mamy pewne mechanizmy bezpieczeństwa. Kompilator oraz zestaw testów powinien powstrzymać innych (i nas samych po kilku latach) do robienia przynajmniej tych bardziej oczywistych głupot. Rzecz jasna, jeśli ktoś chce sobie zrobić krzywdę, to znajdzie sposób, jednak po co to ułatwiać?

    Przenosimy given when then na nowy poziom

    Rozwińmy zasadę, którą przytoczyliśmy wcześniej:

    Komentarze kłamią, kod nigdy!

    My nie będziemy w okolicy, gdy ktoś zechce zrobić coś głupiego z naszymi testami (na przykład dodać kod przygotowujący środowisko już w sekcji When). Natomiast kompilator raczej tam będzie. Więc spróbujmy zrobić z tego użytek i przeróbmy nasze testy.

    Przypomnijmy sobie kod z wcześniejszego przykładu:

    @Test
    void shouldRejectOrderGivenNotEnoughItems() {
      //given
      Store store = new Store();
      store.addItems("pencil", 100);
    
      //when
      Order order = store.makeOrder("pencil", 150);
    
      //then
      assertThat(order.getResult()).isFalse();
    }

    Jak już mówiliśmy, nie są to testy w stylu BDD. Kod nie byłby czytelny dla osoby nietechnicznej. Do tego komentarze zawsze są problematyczne, bo kompilator nigdy ich nie sprawdza. Możemy pozbyć się obu tych problemów za jednym zamachem.

    Wyjdźmy z koncepcji specyfikacji przez przykład i spróbujmy „opowiedzieć” sobie, co sprawdza dany test. Wraz z tą opowieścią pojawi się nam w głowie automatycznie odpowiedź na pytanie, co tak naprawdę jest ważne dla danego test case’a. Właśnie te istotne szczegóły chcemy jak najlepiej wyostrzyć. W naszym przykładzie widzę następującą historię:

    • Zakładając że w sklepie jest 100 ołówków
    • Kiedy składam zamówienie na 150 ołówków
    • To zamówienie nie może być zrealizowane

    Więc po prostu przenieśmy tę opowieść jeden do jednego do naszego testu.

    Poprawiamy test

    @Test
    void shouldRejectOrderGivenNotEnoughItems() {
      givenItemsAdded("pencil", 100);
    
      whenOrderingItems("pencil", 150);
    
      thenOrderCannotBeExecuted();
    }

    Pozostaje nam jedynie zdefiniowanie, co oznaczają poszczególne elementy opowieści:

    private Store store = new Store();
    private Order order;
    
    // test cases
    
    public void givenItemsAdded(String item, int quantity) {
      store.addItems(items, quantity);
    }
    
    public void whenOrderingItems(String item, int quantity) {
      order = store.makeOrder(items, quantity);
    }
    
    public void thenOrderCannotBeExecuted() {
      assertThat(order.getResult()).isFalse();
    }

    Taki kod posiada tę zaletę, że jest napisany jak specyfikacja. Metody ukrywają przed nami szczegóły implementacji, powodując, że bardzo łatwo na pierwszy rzut oka zrozumieć, jaki jest cel danego testu. Skupiamy się na aspektach biznesowych, na tym co robi nasz kod, a nie – jak.

    Do tego zauważmy, że nowoutworzone metody tworzą bardzo zgrabny zestaw elementów, za pomocą których możemy łatwo dopisywać kolejne test case’y:

    @Test
    void shouldAcceptOrderGivenEnoughItems() {
      givenItemsAdded("pencil", 200);
    
      whenOrderingItems("pencil", 150);
    
      thenOrderIsExecuted();
    }
    
    public void thenOrderIsExecuted() {
      assertThat(order.getResult()).isTrue();
    }

    Poczekaj! To jeszcze nie koniec zalet!

    Kolejną zaletą jest to, że ponieważ szczegóły implementacyjne są enkapsulowane. Wyobraźmy sobie, że chcemy pobierać informację o dostępności produktu z zewnętrznego systemu zamiast ustawiać to w obiekcie.

    Tymczasem, dzięki enkapsulacji, przy zmianie sposobu działania testowanej metody musimy dokonać zmiany tylko w jednym miejscu. Same test case’y pozostają bez zmian. Co logiczne, ponieważ tak naprawdę wymagania biznesowe pozostały te same. Z punktu widzenia użytkownika system wciąż działa tak samo. Zobaczmy jak mogłaby wyglądać zmieniona metoda do ustawiania warunków początkowych testów, jeśli do sprawdzania dostępności produktów używalibyśmy zewnętrznego systemu.

    public void givenItemsAdded(String item, int quantity) {
      when(externalSystem.checkAvailability(item)).thenReturn(quantity);
    }

    I tyle. Reszta kodu testów pozostaje co do znaku identyczna.

    Zastosowanie w bardziej skomplikowanym teście

    Powyższy przykład jest banalnie prosty i można stawiać tezę, że zysk z zastosowania tej techniki nie przewyższa poniesionego wysiłku. Tak naprawdę jednak prawdziwa moc tego podejścia pokazuje się, gdy nasza logika jest bardziej złożona. Zobaczmy kolejny przykład, gdzie składanie zamówienia staje się dużo bardziej skomplikowane.

    @Test
    void shouldGiveCustomerADiscountIfHeHasEnoughLoyaltyPoints() {
      //given
      when(availabilitySystem.checkAvailability("pencil"))
        .thenReturn(200);
      when(customerSystem.getUserData("User1").thenReturn(USER_DATA);
      when(loyaltyProgramSystem.getLoyaltyPoints("User1").thenReturn(1050)
      
      store.loadAvailabilityInformation();
    
      //when
      Order order = store.makeOrder("pencil", 150);
    
      //then
      assertThat(order.getPrice()).isEqualTo(60);
    }

    Zwróćmy najpierw uwagę na linijkę nr 5. Wygląda to na setup, który prawdopodobnie będzie wspólny dla wszystkich test case’ów. Po zweryfikowaniu, że tak będzie, przenosimy więc to do osobnej metody setupowej. Pamiętajmy, że jedną z dewiz nam przyświęcających jest to, aby w teście znajdował się jedynie kod, który pomoże zrozumieć, na czym polega dany use case, więc korzystajmy z takich okazji, by nasze testy uprościć.

    @BeforeEach
    void setup() {
      when(customerSystem.getUserData("User1").thenReturn(USER_DATA);
    }

    Idąc dalej, spróbujmy wydzielić pozostałe klauzule when() w postaci metod, które opisują ustawienia testu w sposób bardziej biznesowy

    @Test
    void shouldGiveCustomerADiscountIfHeHasEnoughLoyaltyPoints() {
      //given
      givenItemAvailability("pencil", 200);
      givenLoyaltyPoints(1050);
    
      //when
      Order order = store.makeOrder("pencil", 150);
    
      //then
      assertThat(order.getPrice()).isEqualTo(60);
    }
    
    void givenItemAvailability(String item, int quantity) {
      when(availabilitySystem.checkAvailability(item)).thenReturn(quantity);
      store.loadAvailabilityInformation();
    }
    
    void givenLoyaltyPoints(int points) {
      when(loyaltyProgramSystem.getLoyaltyPoints("User1").thenReturn(points)
    }

    Możemy również dodać metody enkapsulujące asercje oraz samą akcje, podobnie jak we wcześniejszym przykładzie. W tym momencie dochodzimy do wniosku, że komentarze given when then są w zasadzie zbędne, więc możemy je usunąć. Zobaczmy, jak wyglądałby ostateczny kształt naszego testu. Zwróćmy uwagę, jak udało się przy okazji stworzyć cegiełki, z których można łatwo budować następne test case’y.

    @Test
    void shouldGiveCustomerADiscountIfHeHasEnoughLoyaltyPoints() {
      givenItemAvailability("pencil", 200);
      givenLoyaltyPoints(1050);
    
      whenOrderingItems("pencil", 150);
    
      thenPriceIs(60);
    }
    
    @Test
    void shouldGiveCustomerAStandardPriceIfHeHasNotEnoughLoyaltyPoints() {
      givenItemAvailability("pencil", 200);
      givenLoyaltyPoints(950);
    
      whenOrderingItems("pencil", 150);
    
      thenPriceIs(75);
    }
    
    @Test
    void shouldNotExecuteOrderIfNotEnoughItems() {
      givenItemAvailability("pencil", 100);
    
      whenOrderingItems("pencil", 150);  
    
      thenOrderIsNotExecuted();
    }
    
    public void thenOrderIsNotExecuted() {
      assertThat(order.getResult()).isFalse();
    }

    Dopisywanie kolejnych test case’ów, z których każdy byłby równie dobrze zrozumiały dla osoby nietechnicznej jest teraz prostsze niż kiedykolwiek. Działa to też w drugą stronę. Dzięki przetłumaczeniu konceptów biznesowych na techniczne możemy łatwo tłumaczyć wymagania na nowe testy, ułatwiając sobie pisanie kodu.

    Podsumowując

    Koncepcja pisania testów według maniery given when then nie jest pozbawiona sensu. Chciałem jednak pokazać, że można pójść dalej, aby móc wycisnąć z tego podejścia więcej i pozbyć się pewnych problemów z nim związanych. Oparcie się na kodzie zamiast komentarzach sprawia, że kod naszych testów staje się bardziej skupiony na problemie biznesowym, a mniej na technicznej implementacji oraz jest mniej podatny na rozjazd testów z kodem produkcyjnym w przyszłości. To wszystko jest kolejną małą cegiełką czyniącą nasze testy bardziej użytecznymi.