Często unit testy nie są przez programistów traktowane jak prawdziwy kod. Są dla nich jedynie narzędziem do osiągnięcia określonego celu – sprawdzenia poprawności implementacji. Przez to testy stają się trudne w utrzymaniu albo wykonują się zbyt długo. Przez co uniemożliwiają pracę zgodnie z TDD i nie mają wartości dokumentacyjnej. Istnieją jednak proste zasady tłumaczące, jak powinny wyglądać dobrze napisane testy.
Test FIRST
Pisząc kod powinniśmy trzymać się zasad SOLID, czyli kod powinien być solidny, a dodatkowo każda liter oznacza jedną zasadę. Podobny zestaw zasad wymyślono dla unit testów tworząc zasady test FIRST. Jest to oczywiście nawiązanie do TDD, gdzie najpierw piszemy failujący test, a dopiero potem kod, który go przechodzi. Poniżej omawiam znaczenie każdej z liter.
F – fast
Testy powinny się wykonywać szybko. Szybko oznacza kilka sekund. Tylko wtedy developer będzie je często uruchamiał, aby otrzymać feedback dotyczący przed chwilą napisanego fragmentu kodu. W przeciwnym wypadku naturalnie będziemy dążyli do pisania większych fragmentów przed sprawdzeniem i odchodzili w ten sposób od TDD. Dlatego w testach niedopuszczalne jest stosowanie delayów, a testując np. błędy przepełnienia dużych struktur danych nie wypełniamy ich w całości, tylko stosujemy mocki.
I – isolated
Pojedynczy test nie zależy od pozostałych, ani nie wpływa na ich wykonanie. Musimy mieć możliwość puszczenia jednego testu z zestawu, kilku testów w zmienionej kolejności, albo tego samego testu kilka razy pod rząd i za każdym razem otrzymać ten sam wynik. Dlatego jeśli testy wymagają jakiejś konkretnej konfiguracji, niedopuszczalne jest bazowanie na ustawieniach z innego testu. Powinniśmy wtedy wykorzystać funkcje setup i teardown udostępniane przez framework do testów.
R- repeatable
Ten sam test puszczony kilkukrotnie daje ten sam wynik. Poza tym testy puszczone na różnych maszynach również powinny dać ten sam wynik. Powinniśmy więc pisać testy tak, aby nie uzależniać się od konkretnej platformy. Nie powinniśmy również polegać na elementach, które zmieniają się między wywołaniami jak np. liczby losowe, czy czas. Chodzi o to, aby ewentualne błędy były możliwe do reprodukcji i aby przechodzące testy dawały pewność, że dany problem jest sprawdzony.
S – self-verifying
Test kończy się jasnym komunikatem PASS, albo FAIL. Niedopuszczalne jest zmuszanie użytkownika do interpretacji wyniku. Mamy na przykład test, króry zwraca wartość 42, a potem musimy ręcznie sprawdzić, że poniżej 25 jest FAIL, a powyżej PASS. W takim wypadku dodajemy kolejne miejsce, gdzie można zaindukować błąd. Jeżeli sprawdzi to automat, zawsze dostaniemy poprawny rezultat, a poza tym nie tracimy czasu na jakieś dodatkowe operacje.
T – timely
Każdy test powinien być pisany wtedy, kiedy jest potrzebny, czyli kiedy dotychczasowe testy przechodzą, ale nie pokrywają wszystkich wymagań. Powinniśmy unikać pisania wielu testów, a potem implementacji przechodzącej wszystkie testy na raz. W ten sposób w każdej iteracji Red – Green – Refactor możemy skupić się na jednym konkretnym problemie i nie dopisujemy na raz dużych, skomplikowanych fragmentów kodu bez szybkiej informacji zwrotnej o błędach. Często zdarza się, że na początku mamy dużo pomysłów na nowe testy. Zamiast je wszystkie od razu implementować, możemy zrobić listę np. w postaci komentarza i wybierać z niej kolejne elementy.
Dobre praktyki
Dobre unit testy nie służą jedynie do sprawdzenia, czy kod działa poprawnie. Są również formą dokumentacji zawierającej zbiór wymagań, czy przypadki użycia. Aby można je było wykorzystywać w tym celu, muszą być napisane w sposób przejrzysty, a ich nazwy powinny ułatwiać późniejsze wyszukiwanie
Dlatego właśnie pojedynczy test powinien sprawdzać jeden logiczny assert. Taki logiczny assert może składać się z kilku fizycznych assertów. Przykładowo dla logicznego assertu „liczba mieści się w zakresie od 5 do 10”, mamy fizyczne asserty dla liczb 4 – poza zakresem, 5 – w zakresie, 10 – w zakresie, 11 – poza zakresem. Logiczny assert jest tu kwestią umowną. W powyższym przykładzie jeden programista może zrobić jeden test, a inny rozbić go na dwa logiczne asserty – sprawdzenie dolnej i górnej granicy. Oba podejścia są poprawne, a granulacja zależy od indywidualnych upodobań. Należy tylko unikać skrajności – jeden test sprawdzający, że „Funkcja działa poprawnie” nie jest najlepszym rozwiązaniem.
Struktura pojedynczego testu
Aby ułatwić tworzenie czytelnych testów powstało kilka konwencji opisujących strukturę pojedynczego testu:
- Given-When-Then
- Arrange-Act-Assert
- Setup-Exercise-Verify-Cleanup
Wszystkie te konwencje opisują tak naprawdę ten sam szablon testu. Najpierw ustawiamy dla testu warunki początkowe (Given / Arrange / Setup), następnie wykonujemy testowany kod (When / Act / Exercise), po czym sprawdzamy czy jego rezultat jest zgodny z oczekiwanym (Then / Assert / Verify). W trzecim podpunkcie występuje jeszcze faza Cleanup odpowiadająca za posprzątanie środowiska przed kolejnym testem. We frameworkach testowych opartych na xUnit nie umieszczamy takiego kodu bezpośrednio w teście, tylko w funkcji Teardown wywołującej się po każdym teście.
Schemat Given-When-Then jest bardzo przydatny również przy tworzeniu nazwy testu. Dzięki niemu otrzymujemy prosty szablon nazwy, w której zawrzemy hasłowy opis warunków początkowych, testowanej operacji i spodziewanego wyniku.
Grupy testów
Frameworki do unit testów posiadają opcję tworzenia grup testów. Grupa otrzymuje charakterystyczną nazwę, a wszystkie testy w tej grupie wykorzystują wspólne funkcje Setup i Teardown służące do początkowej konfiguracji i przywracania środowiska po teście. W ramach jednego builda testowego można uruchomić kilka grup. Jest to mechanizm służący do łączenia ze sobą podobnych testów posiadających tą samą konfigurację. Zapewnia on zmniejszenie duplikacji kodu oraz zwiększenie czytelności przez logiczne posegregowanie testów.
Podsumowanie
Unit testy są ważnym elementem systemu. Testy sprawdzają, czy system działa zgodnie z założeniami oraz jeśli stosujemy TDD pomagają w projektowaniu poszczególnych elementów i w utrzymywaniu czystego kodu. Jednak nawet po napisaniu danej funkcjonalności w dalszym ciągu ich zadanie nie ogranicza się jedynie do sprawdzania błędów. Pełnią również rolę dokumentacyjną. Musimy więc dbać o testy pisząc je zgodnie z przytoczonymi powyżej zasadami, ale również musimy zachować czujność wprowadzając zmiany, aby testy z czasem nie traciły na aktualności i czystości.
12 września 2017 at 09:15
Hej napisałem kiedyś art na ten temat (przyznam że przykłady są w Javie, ale ogólne zasady np. mokowanie pozostają te same niezależnie od platformy), być może Ciebie zainteresuje: https://marcin-chwedczuk.github.io/zen-and-the-art-of-unit-testing