TESTOWANIE APLIKACJI – TESTY INTEGRACYJNE, JEDNOSTKOWE I SMOKE TESTY - DEVPARK
Development / Laravel

Aplikacje internetowe, bez względu na przeznaczenie, wymagają przeprowadzania testów, testowanie pozwala sprawdzić czy początkowe założenia systemu zostały spełnione, czy aplikacja działa bez błędów oraz czy aplikacja może zostać uruchomiona na serwerze produkcyjnym.

Istnieją dwa główne podejścia do testowania, testy ręczne i automatyczne.

Testy ręczne wymagają przeglądarki oraz osoby, która sprawdzi aplikacje. Pomimo, że taki proces musi się odbyć, jest to czasochłonne zajęcie. W procesie wytwarzania systemu informatycznego, poza programowaniem, mamy wiele innych aspektów zabierających cenny czas (jak choćby planowanie), dlatego staramy się zautomatyzować co tylko możliwe – i tutaj dużym polem do popisu jest automatyzacja testów.

Obecnie testowanie automatyczne to integralna część wytwarzania aplikacji. Poniżej skupimy się na roli testów automatycznych w aplikacji od strony backendu. Zagadnienie testowania jest bardzo szerokie i nie sposób przedstawić całości w jednym artykule. Dodatkowa polaryzacja testowania zaowocowała szybkim rozwojem wielu gałęzi testowania.

Testujemy aplikacje zarówno biznesowe jak i open-soursowe. W szeroko dostępnym wolnym oprogramowaniu, można znaleźć mnóstwo świetnych rozwiązań, które warto wykorzystać.

Przykładowo, jeśli chcesz zaimplementować jakąś paczkę (czyli kod dostarczony z zewnątrz) – zastanawiamy się, czy taki kod spełni nasze oczekiwania i czy rzeczywiście działa tak jak został opisany w ReadMe. Odpowiedź znajdziemy w testach. Jeżeli nie istnieją – jest to zły znak i lepiej poszukajmy innej paczki. Testy potwierdzą przydatność paczki, dodatkowo po ich przeanalizowaniu powinniśmy poznać pełną funkcjonalność paczki oraz ewentualne ograniczenia wynikające z warunków brzegowych, stosowanych parametrów wejściowych w testach

Ten artykuł skupi się na jednym z głównych podejść do pisania aplikacji w metodykach zwinnych – Test Driven Development (TDD) i narzędziu PHPUnit.

Laravel zapewnia gotowe i proste środkowisko testowe. W głównym katalogu naszego projektu znajdziemy plik phpunit.xml, który pozwala na dostosowania środowiska testowanego do własnych potrzeb. Natomiast, nasze testy powinny trafić do katalogu tests.

Smoke Tests

Piramida testów pokazuje jak testy ze sobą współpracują. Na samym szczycie widzimy smoke testy (testy dymne) będące częścią grupy zwanej testami weryfikacyjnymi. Pozwalają one sprawdzić podstawowe funkcjonalności aplikacji bez zagłębiania się w szczegóły i rezultaty działania aplikacji. Smoke testy traktujemy jako pierwszą linię obrony aplikacji przed błędami. Przykładowo, mając API z endpointem zwracającym kolekcję obiektów, warto napisać test, który sprawdzi czy endpoint w ogóle działa i zwraca oczekiwany kod statusu 200. Jeżeli tak, mamy pewność, że nie mamy wewnętrznych błędów serwera, co by się objawiało kod 500.

Kod powyżej zawiera prosty smoke test, który został zamknięty w czterech liniach kodu.

Pierwsze polecenie symuluję odwiedzenie strony przez przeglądarkę i zapisuje odpowiedź do zmiennej response.

Kolejny trzy linie sprawdzają zapamiętaną odpowiedź, tj.:

  • isOk – sprawdzenie poprawności statusu odpowiedzi HTTP
  • assertViewIs – sprawdzenie poprawności użytego widoku do renderingu strony
  • assertSee – sprawdzenie poprawności wypełnienia widoku wskazaną treścią.

Jak widać, tworzenie smoke testów jest proste, daje szybkie rezultaty, a co najważniejsze, upewnia nas w tym, że aplikacja działa.

Łatwo wskazać najważniejsze zalety smoke testów. Niewątpliwie jedną z nich jest, pokrycie podstawowych funkcjonalności bez zagłębiania się w szczegóły oraz zbudowanie stabilnego szkieletu pozwalającego na rozbudowywanie testów.

Jakie są przykładowe zastosowania?

Nasz framework rozwija się, pojawiają się kolejne wydania – chcąc utrzymywać aplikację warto dbać o aktualizowanie kodu bazowego frameworka. Po każdej aktualizacji sprawdzany czy nasze testy działają, co daje na pewność że aplikacja również działa.

Podobnie, jeżeli zdarza nam się pracować z tzw. legacy code, wtedy testy z reguły nie istnieją. Jeżeli rozpoczniemy pracę od napisania smoke testów, poznamy jak działa nasza aplikacja oraz zmniejszamy ryzyko krytycznych błędów podczas wprowadzania zmian (np. refaktoringu) w kodzie.

Główną zaletą smoke testów jest także szybkość ich wykonywania. Ma to szczególne wykorzystanie w przypadku testowania systemów, z dużą ilością testów, których wykonanie zajmuje znaczącą ilość czasu. W takim przypadku najpierw uruchamiane są smoke testy. W przypadku niepowodzenia – nie ma sensu wykonywać wszystkich pozostałych (bardziej czasochłonnych) testów. Sprzęgnięcie tych testów z CI, pozwala bardzo szybko zorientować się programiście, że coś poszło nie tak i podjąć odpowiednie działania.

Testy integracyjne

Testowanie na tym poziomie pokaże błędy między interfejsami aplikacji i problemy z integralnością komponentów systemu.

Testy integracyjne traktują komponent (system) jak czarną skrzynkę, sprawdzając integralność bez zagłębiania się w szczegóły implementacji. Wiemy jak dana funkcjonalność powinna działać i jakie rezultaty powinna zwrócić. Nasze weryfikacja polega na sprawdzeniu czy to co otrzymaliśmy jako wynik końcowy zgadza się z oczekiwanymi rezultatami przy określonych danych wejściowych.

Co w takim razie może nie działać jeżeli rezultaty nie są spójne z oczekiwaniami? Problemy mogą wystąpić przy braku, bądź złej implementacji funkcji, błędach interfejsów, które powinny zapewniać integralność systemu. Testy mogą wskazać błędy w strukturach danych lub problem z dostępem do bazy danych. Testy pokażą zachowanie aplikacji jako całość. Testowana aplikacja zmienia swój stan w trakcie procesu testowania. Dobrze napisane testy pozwolą zaobserwować błędy związane ze stanem początkowym systemu lub w procesie terminowania aplikacji.

Biorąc pod uwagę wspomniane aspekty, testy integracyjne przetestują produkt finalny, naszą aplikację.

Widzimy tutaj analogie do produkcji długopisu, który składa się z wielu elementów, tj. wkład, sprężyna, obudowa i przycisk, a po złożeniu każdy element powinien do siebie pasować tworząc długopis, którego głównym zadaniem jest pisanie i to musimy przetestować.

Dzisiejsze aplikacja są wspierane przez bazy danych. Dane są w zapamiętywane, przechowywane, edytowane i usuwane. W czasie pisania testów musimy pamiętać o prawidłowym stanie bazy danych aplikacji. Laravel, udostępnia narzędzia wspomagające pisanie testów, w tym tworzenie zestawów danych testowych za pomocą fabryk Factories. Definicja przykładowej fabryki dla modelu User poniżej:

Wykorzystując fabrykę, generujemy zestaw danych testowych, które mogą być trzymane w pamięci jak i zapisane w bazie danych.

Wygenerowany obiekt jest pełnoprawnym obiektem, który wykorzystuje aplikacja. Testowanie wyników zapisanych w bazie danych z wykorzystaniem Laravel jest proste, a kod czytelny. Warto wspomnieć chociażby o dostępnych metodach jak:

  • assertDatabaseHas($database_table, $searching_details) – sprawdza poprawność zapisanie danych w bazie danych w ramach wskazanej tabeli.
  • assertDatabaseMissing ($database_table, searching_details) – weryfikuje brak podanych danych w bazie danych w ramach wskazanej tabeli.
  • assertSoftDeleted($database_table, $searching_details) – sprawdza poprawność oznaczenia danych jako usunięte.

Sprawdźmy wspomnianą metodę w teście:

Test weryfikuję poprawność zapisania modelu w bazie danych, wcześniej stworzonego w pamięci za pomocą fabryki.

Jeżeli przeanalizujemy test to widzimy że na początku tworzymy obiekt w pamięci, parsujemy dane do tablicy i zapisujemy do zmiennej data. Następnie dodajemy pola związane z hasłem w celach walidacyjnych. Mając przygotowane dane wysyłamy request do API i sprawdzamy poprawność statusu otrzymanej odpowiedzi, tj. 201, zgodnie ze kodami HTTP. Na zakończenie sprawdzamy poprawność dodania nowego wpisu w bazie danych, co jest równoznaczne z zapamiętaniem użytkownika.

Wcześniej wspomnieliśmy o stanie aplikacji, z tym pojęciem związany jest również stan bazy danych, który musi być spójny z aplikacją. W celu zachowania spójności Laravel udostępnia dwa traity:

  • Illuminate\Foundation\Testing\DatabaseTransactions
  • Illuminate\Foundation\Testing\RefreshDatabase (od wersji Laravel 5.5)

Oba dbają o cofnięcie wszystkich zmian w bazie danych po zakończeniu testu. Daje nam to pewność nie zmienności stanu bazy po wykonaniu testów. Trait RefreshDatabase odświeży bazę danych i wykona wszystkie migracje.

Mając na uwadze wpływ testów na bazę danych zaleca się odseparowanie danych testowych od pozostałych za pomocą testowej instancji bazy danych. Najprościej osiągniemy to definiując testową konfiguracje w pliku app/config/database.php i ustawiając odpowiednie zmienne środowskowe.

Laravel posiada plik phpunit.xml w głównym katalogu produktu. To dobre miejsce aby ustawić odpowiednią testową bazę danych w gałęzi XML:
<env name=”DB_CONNECTION” value=”db_testing”/>

Podsumowując, komponenty możemy testować na wiele sposobów, dokładną listę należy dobrać w zależności od budowanej aplikacji. Istnieją pewne wspólne elementy dla każdej nowoczesnej aplikacji warte do przetestowania:

  • walidacja
  • uwierzytelnienie (Laravel guards, Web, Api – Oauth)
  • autoryzacja (Laravel Policies)
  • baza danych
  • paginacja
  • filtry
  • odpowiedzi (kody i zawartości)

Testy jednostkowe

Celem testów jednostkowych jest walidacja popranego działania małego fragmentu aplikacji. Pod pojęciem małego fragmentu aplikacji rozumiemy najmniejszą możliwą jednostka aplikacji. Taką jednostką może być pojedyncza funkcja bądź metoda należąca do klasy. Z reguły może przyjmować kilka argumentów wejściowych i zwracać jeden obiekt/wartość. Istotne – testy jednostkowe piszemy wyłącznie dla metod publicznych. Nie pisze się testów dla protected i private.

W przypadku testów jednostkowych warto wspomnieć o izolacji testowanej jednostki od wpływu czynników zewnętrznych. Ma to na celu skupić się w teście na testowanej funkcjonalności.

W pomocą przychodzą Atrapy, dalej zwane Mockami. Mock jest obiektem podstawianym w miejsce rzeczywistego obiektu. Celem Mocka jest wymuszenie odpowiedniego obiektu zastępowanego.

W budowaniu Mocków pomocne są dobrze znane frameworki, jak:

  • PHPUnit
  • Phony
  • Prophecy
  • Mockery

Skupmy się na bibliotece Mockery. Jest ona mocno wykorzystywana nie tylko w Laravel ale wielu innych frameworkach PHP.

Budujemy mock za pomocą kilku metod dostępnych w bibliotece. Przyjrzyjmy się fragmentowi kodu poniżej. Na początku inicjujemy Mock. Następnie wybieramy metodę, którą będziemy symulować, deklarujemy liczbę wywołań metody i podajemy co chcemy zwrócić w metodzie. Kolejnym krokiem jest zbindowanie naszego mock z wstrzykiwaniem instancji obiektu przez kontener. Teraz laravelowy ServiceContainer (IoC) podaje nam instancje obiektu Order a w miejsce serwisu płatności wstrzykuje nasz mock. Przewidujemy działania metody send i sprawdzamy poprawności naszego założenia.

Spójrzmy jeszcze na implementacje klasy Order i mokowanej klasy Payment Service:

Instancja klasy Order oczekuje podania service za pomocą Depedency Injection (DI). Wstrzykiwanie zależności czyni kod w pełni testowalny ponieważ serwis może zostać podmieniony przez mock co czynimy w przykładzie powyżej, izolując środowisko testowe od zewnętrznych zależności.

Metodę paid zostawiamy pustą. Jej implementacja nie znaczenia, ponieważ i tak została podmieniona na czas przeprowadzenia testu.

Laravel, jako framework może pochwalić się wieloma wbudowanymi funkcjonalnościami. Wiele z nich jest udostępniania za pomocą serwisów nazywanych Facades. Laravel wyposażył swoje serwisy w pomocnicze metody do budowania testów. Metody te pozwalają mokować laravelowskie Facades w podobny sposób jak ma to miejsce w bibliotece Mockery. Można używać metod shouldReceive, once, andReturn w analogiczny sposób jak we wcześniejszych przykładach.

Przykładowy test, z wykorzystaniem fasady Lang i mockowanie:

Po zamokowaniu metody, za każdym razem, kiedy laravelowy kontener napotka wywołanie metody trans za pomocą fasady Lang.

Testy jednostkowe znajdują się na samym dole Piramidy Testów co nie oznacza, że są one najmniej istotne. Ich zadaniem jest testowanie logiki aplikacji. Aby to było możliwe, a jednocześnie testy były zrozumiałe dla programisty, testowanie logiki odbywa się w małych częściach.

Jakie się zalety testów jednostkowych?

Najważniejsza to sprawdzenie poprawności poszczególnych części kodu i pewność, że logika tej części działa na każdym etapie developmentu, zmian i utrzymania kodu. Wcześniej wspomnieliśmy, że testy jednostkowe z założenia są krótkie i nieskomplikowane a to implikuje proste debugowanie kodu. Błędy pojawiające się na liście argumentów lub w zwracanej wartości zostaną wykryte na wstępnym procesie rozwoju aplikacji. Błędy zostaną naprawione a sytuacja już się nie pojawi w przyszłości. Dlaczego? Każda próba usunięcia poprawki spowoduje zatrzymanie testów. W rezultacie kod staje się niezawodny, a rozwój aplikacji przebiega bez zakłóceń, tak więc jest tańszy dla biznesu i przyjemniejszy dla programisty.

Rozwój aplikacji wiąże się z wprowadzaniem zmian w specyfikacji a to pociąga za sobą zmiany w kodzie i testach. Czasami trzeba przerobić test lub przenieść go w inne miejsce (w obrębie projektu) ale w przypadku testowanego kodu w połączeniu z poprawną architekturą, takie zmiany są proste i nie powodują problemów ze stabilnością całej aplikacji.

Napisanie i utrzymanie testów wiąże się z dodatkowym nakładem pracy (czyli także kosztem). Praktyka pokazuje jednak, że w trakcie developmentu i wprowadzania zamian w systemie, koszty te są niższe, niż w przypadku systemów tworzonych bez testów. Poza kontrolą nad systemem, mają one dodatkowe korzyści jak chociażby łatwość i bezpieczeństwo wprowadzenia nowego programisty do zespołu pracującego nad projektem (czy też w ogóle wymiany zespołu programistycznego). Reasumując – są warte swojej ceny i współcześnie, żaden szanujący się programista nie powinien ich pomijać.

Autor: Kamil Piętka, backend programmer w Devpark Laravel Team