Dla programistów i nie tylko dla nich. Na przykładzie języka PHP.
Kilka słów wprowadzenia, jeśli jesteś zainteresowany tematyką pisania testów jednostkowych, to w przypadku języka PHP można by Cię już zaliczyć do nielicznego grona profesjonalistów wykorzystujących TDD.
Tematyka pisania testów automatycznych jest dość szerokim pojęciem. W tym krótkim poradniku skupiam się na zagadnieniach związanych z rodzajami testów usytuowanych u podstawy piramidy testów (rys. nr 1), czyli tych które powinny być pisane równolegle i bezpośrednio przez twórców samego kodu – przez programistów.
Nie skupiam się tutaj na testach wyższego poziomu, jak testy akceptacyjne czy end-to-end tworzone przez zespoły QA.
Poniższy poradnik nie jest też samouczkiem do pisania testów od podstaw. To, jak ustawić środowisko testowe, czym jest mockowanie, fakeowanie, jak przygotowywać struktury danych za pomocą fakerów czy fabryk, często jest mocno zależne od używanego frameworka i nie jest przedmiotem tego poradnika.
My skupimy się wyłącznie na tym, jak kod testów możemy uczynić bardziej zrozumiałym i czytelnym, przy założeniu, że już potrafisz pisać działające testy.
Jeśli już masz doświadczenie w pisaniu testów i chciałbyś podnieść ich jakość, możesz pominąć dalszą część wprowadzenia i przejść do następnego rozdziału, bo na pewno już dobrze znane są Ci korzyści wypływające ze stosowania TDD.
Zatem dla rozpoczynających przygodę w profesjonalnym podejściu do wytwarzania oprogramowania, kilka słów o korzyściach, które przynosi stosowanie testów automatycznych pisanych przez programistów.
Na pierwszy rzut oka pisanie testów wydaje się być bardziej kosztowne niż wartościowe.
W przypadku małych aplikacji z prostą logiką, które nie będą rozwijane i są tylko stworzone na chwilę jako proof of concept – tak, pewnie takie spojrzenie ma rację bytu.
Jeśli jednak tworzona aplikacja ma być rozwijana, jej logika biznesowa będzie coraz bardziej skomplikowana, a jej używanie planowane jest na dłuższy czas, na przykład na lata, to takie myślenie jest bardzo zwodnicze, gdyż nie uwzględnia etapów rozwoju, wprowadzania zmian oraz utrzymania aplikacji.
Zacznijmy od utrzymania aplikacji. Pod tym pojęciem kryje się nie tylko naprawa bieżących problemów czy optymalizacja kodu systemu lub infrastruktury. W dużej mierze ten temat związany jest bezpośrednio z ludźmi pracującymi nad projektem. Często osoby pracujące nad aplikacją albo się zmieniają, albo zespół programistów powiększa się o nowych informatyków. W rezultacie nad kodem pracują ludzie, którzy nie byli jego autorami i dlatego często obawiają się wprowadzania zmian w kodzie. Automatyczne testowanie wspiera wprowadzanie nowych osób do zespołu i wprowadzanie przez nich zmian.
I tu dochodzimy do wprowadzania zmian i poprawek w kodzie. Dzięki poprawnie napisanym testom jest nam o wiele łatwiej dokonywać zmian i wyłapywać ewentualne problemy na bardzo wczesnym etapie pisania kodu, a następnie nanosić poprawki jeszcze przed wypuszczeniem aplikacji na serwer. W dodatku wyłapywanie błędów następuje automatycznie, bez ingerencji człowieka. Ułatwia to pracę nowym programistom, a także autorowi systemu. Często przecież zdarza się, że konieczne są zmiany w kodzie, nad którym pracowało się dawno temu i nie do końca pamięta się już o wszystkich powiązaniach w logice działania systemu.
Z powyższego wywodu wynika, że testy pomagają nam oraz ograniczają koszty w regresji systemu.
TDD jednak nie polega na dopisywaniu testów do istniejącego, pozornie działającego systemu. Tak – czasem trzeba to zrobić, jeśli autor nie zrobił tego od razu, jednakże w TDD testy powinny być tworzone jednocześnie z powstającym kodem, lekko go wyprzedzając. Z pozoru są one dodatkowym obciążeniem, ale tak naprawdę, szczególnie w przypadku bardziej zawiłej logiki biznesowej lub skomplikowanych algorytmów, przyspieszają one wytworzenie kodu działającego zgodnie z wytycznymi.
Dobrze pisane testy są narzędziem pomocnym do tworzenia od podstaw nowego kodu. Przyspieszają debugowanie oraz planowanie w zadanych parametrach wejściowych konkretnego kawałka kodu. Skłaniają one programistę do tego, aby lepiej zastanowił się nad podziałem pisanego kodu i ścieżek typu happy path, failed and error paths. W ten sposób wymuszają one na programiście decoupling kodu, a zatem pisanie bardziej czytelnego kodu źródłowego.
Dodatkowym atutem jest pomoc przy refactoringu kodu. Z początku nie zawsze jest się w stanie napisać od razu kod w jego najlepszej i czystej formie – z różnych przyczyn, np. presji czasu, nieznajomości wszystkich aspektów technicznych, czy np. implementacji źle udokumentowanego API from 3rd services. Taki kod docelowo chcemy zrefaktoryzować, jednakże jego działanie nie może ulec zmianie (czyli musi dalej robić to, co robił). Tylko poprawnie napisane testy są w stanie nam zagwarantować dalszą poprawność działania i jednocześnie ułatwić i przyspieszyć cały proces refaktoryzacji.
Dodatkowym plusem poprawnie napisanych testów jest stworzenie dokumentacji użycia/zasad działania poszczególnych elementów kodu. Kod taki dokumentuje się sam, co UniTree unaocznia w sposób graficzny, zwiększając komfort pracy. Jest to dodatkowy aspekt, który ułatwia dołączanie nowych programistów do zespołu pracującego nad kodem i ogranicza koszty wdrożenia się takiej osoby.
Pierwszym od czego warto zacząć jest rozpoznanie dwóch różniących się od siebie typów testów, będących podstawą piramidy testów.
Wprowadźmy podział testów na dwie grupy: te, które badają konkretne działanie poszczególnego kawałka kodu naszej aplikacji oraz testy badające cały algorytm mogący składać się z wielu klas kodu.
Pierwszą grupę określmy jako „testy jednostkowe”, stanowiące podstawę piramidy, drugą jako „testy integracyjne”, usytuowane w piramidzie bezpośrednio ponad testami jednostkowymi.
Jaka jest zasadnicza różnica? Z grubsza – test jednostkowy powinien testować konkretne, jedno działanie, a w praktyce konkretną jedną metodę publiczną w klasie bez jej zależności. Jeśli klasa wykorzystuje obiekty lub inne metody publiczne – powinny one zostać zamockowane, tak aby odciąć możliwy wpływ kodu nie będącego elementem badanym przez test.
Poniżej przykładowy test integracyjny i jednostkowy dla tej samej metody w klasie.
Przykład testu integracyjnego
/** * @test * @covers WpCategorySynchronizer::synchronize */ public function synchronize(): void { $synchronizer = $this->newSynchronizer(new ApiWordpress()); $result = $synchronizer ->synchronize(); $this->assertDatabaseHas('meals', [ 'external_source' => 'WORDPRESS', 'title' => 'How To Make Chicken Fajitas (Easy Fajita Recipe)', ]); }
Przykład testu jednostkowego
/** * @test * @covers WpCategorySynchronizer::synchronize */ public function synchronize() { $recipe_stream = $this->mockRecipeStream(); $api_wp = \Mockery::mock(ApiWordpress::class); $api_wp->shouldReceive('startRecipeStream')->once()->andReturn($recipe_stream); $synchronizer = $this->newSynchronizer($api_wp); $result = $synchronizer->synchronize(); $this->assertInstanceOf(Report::class, $result); }
Otóż w idealnym świecie przyjmuje się, że aplikacja powinna być w 100% pokryta testami jednostkowymi, co często powoduje, że testy nie powstają wcale, ponieważ takie oczekiwanie wydają się nieracjonalne. W naszej opinii powinno się rozważać pokrycie aplikacji testami za pomocą zestawu testów: integracyjnych (czasem nazywanych end-to-end) i jednostkowych.
Testy jednostkowe powinny pokrywać szczególne przypadki logiki biznesowej, podczas gdy testami integracyjnymi pokrywamy „proste” elementy systemu – np. requesty wykonujące proste operacje CRUD nieposiadające żadnej dodatkowej logiki biznesowej. W takim przypadku o wiele szybsze jest pokrycie całej aplikacji sensownymi i jednocześnie racjonalnymi testami automatycznymi. Często też można zrezygnować z testowania „narzędzia” dostarczanego przez framework zakładając, że wykonuje ono swoje zadanie i ma już swoje testy (np. ORM i metoda do pobrania wszystkich rekordów w bazie lub szukająca konkretnego rekordu po jego unikalnym Id).
W strukturze drzewa katalogów warto wprowadzić wyraźny podział, gdzie umieszczamy testy jednostkowe a gdzie integracyjne, o czym napiszę w drugiej części artykułu, na którą już zapraszam.
Andrzej Fenzel, Pomysłodawca UniTree.app