Testy jednostkowe – co to jest i po co się to robi?

Wszyscy chcielibyśmy, aby nasz kod miał dokumentację, można było w dowolnym momencie w szybki i automatyczny sposób sprawdzić, czy działa poprawnie po wprowadzonych zmianach, a także w przypadku wystąpienia błędu od razu wiedzieć gdzie i co jest nie tak. Jak upiec te trzy pieczenie na jednym ogniu niskim kosztem? Odpowiedzią są testy jednostkowe.

Czym są testy jednostkowe?

Testy jednostkowe to nic innego jak badanie czy testowane przez nas obiekty zareagują zgodnie z oczekiwaniami na określone bodźce. Zazwyczaj sprowadza się to do uruchamiania poszczególnych publicznych metod danej klasy bądź nawet po prostu funkcji i sprawdzania, czy otrzymamy pożądany rezultat. Przykład? Testem funkcji dodaj(a,b) będzie sprawdzenie, czy wywołanie jej z parametrami 3,-2 da rezultat 1.

Nazwa testów jednostkowych wynika z ograniczenia obiektów testowanych do absolutnego minimum. Skupiamy się na jasno określonym wejściu i wyjściu w trakcie testu, a wszystko dzieje się w niezmiennych, kontrolowanych warunkach. Odwołując się do powyższego przykładu załóżmy, że mamy aplikację komputerową typu „kalkulator”, która jest w stanie wykonywać zaawansowane operacje matematyczne. Testy jednostkowe tej aplikacji będą odwoływać się wyłącznie do poszczególnych operacji. Poprawnym testem jednostkowym będzie sprawdzenie, czy funkcja podziel zwróci poprawny wynik podczas dzielenia 4 przez 2. Testem jednostkowym nie będzie sprawdzenie, czy po kliknięciu „4”, „/”, „2” a na koniec „=” wynikiem będzie 2. Taki rodzaj testowania to testy E2E (end to end). Nie zapominajmy jednak o negatywnych ścieżkach wykonania metody, tj. gdy wykonywanie funkcji zakończy się niepowodzeniem. Pozostając jeszcze chwilę przy tym przykładzie, należałoby napisać drugi test dla tej funkcji, który sprawdzi, czy funkcja wywoła błąd/wyjątek w momencie próby podzielenia przez zero.

Jak radzić sobie z zależnościami?

Brzmi banalnie, gorzej w praktyce. Bardzo szybko dojdziemy do momentu, kiedy testowane funkcje są zależne od innych funkcji. Jeśli pozwolimy im propagować dalej łańcuch zależności przez kolejne funkcje, klasy i całe biblioteki bardzo szybko natkniemy się na problemy np. brak połączenia z bazą danych, plik o podanym adresie nie istnieje, serwer docelowy nie odpowiada… to wszystko oznaki błędnych testów jednostkowych. Oznacza to, że nasz test jednostkowy stał się testem integracyjnym, tj. testujemy jak nasza funkcja współdziała z innymi elementami aplikacji. W testach jednostkowych nie chcemy żeby na rezultat testowanej funkcji rzutowały wyniki działania innych funkcji. Robi się wtedy założenie że wszystkie zależne funkcje działają poprawnie i nie trzeba się nimi przejmować, skupiając się wyłącznie na obiekcie testów. Żeby to założenie wprowadzić w życie, musimy zamockować wszystkie zależności danej funkcji, tj. podmienić realne funkcje na ich mocki -„niby funkcje”, które zwrócą z góry narzucony wynik.

Dla przykładu weźmy funkcję getFormattedResult(result) która zwróci rezultat operacji matematycznych w zależności od funkcji getFractionSeparator(), która to zwraca przecinek bądź kropkę w zależności od ustawień językowych. W takim wypadku do testów getFormattedResult mockujemy funkcję getFractionSeparator tak, aby zwróciła odpowiadający nam znak (np. „,”) i sprawdzamy, czy wynikiem getFormattedResult(123.45) będzie „123,45”. Konkretny sposób mockowania zależy od języka i używanej biblioteki do testowania.

Pisanie testów to strata dużej ilości czasu. Szef mi nie pozwoli.

Pisanie testów jednostkowych z początku pochłania ogromną ilość czasu. Podchodząc do tego trzeba z góry założyć, iż poświęcimy wiele godzin treningu, zanim dojdziemy do wprawy. Przekonanie nietechnicznego szefa, aby poświęcić dodatkowe dziesiątki godzin na naukę w trakcie godzin pracy czegoś nowego, co nie przyniesie natychmiastowych korzyści jest sporym, często niewykonalnym wyzwaniem. W takim wypadku sugeruję naukę w domu po godzinach, a następnie wprowadzanie testów jednostkowych stopniowo i niezależnie od wiedzy klienta. Z mojego doświadczenia wynika, że tworzenie testów jednostkowych posiadając w nich spore doświadczenie wydłuża mimo wszystko development o jakieś 10-25%. Warto więc stopniowo wyceniać zadania troszkę zawyżając, aby zrobić sobie okno czasowe na uzupełnienie testów. Nawet na początku szczątkowe pokrycie testami najbardziej wrażliwych elementów aplikacji jest dużo lepsze niż zupełny brak testów.

Jak pisać poprawnie testy jednostkowe i wycisnąć z nich jak najwięcej?

Coś tam sobie popiszemy, coś tam się posprawdza. Ale jak to się ma do problemów przedstawionych w pierwszym akapicie?

  1. Dostajemy narzędzie do automatycznego i szybkiego zbadania poprawności działania programu po wprowadzeniu zmian. Unikamy w ten sposób tzw. regresji, czyli cytując wikipedię „zjawisko powstawania błędów w oprogramowaniu po zamierzonej zmianie w jakiejś części kodu programu (np. po wprowadzeniu poprawki dla innego błędu). Skutkiem tych zmian może być błędne działanie innej funkcji programu, która w poprzednich wersjach działała prawidłowo.”. W momencie wystąpienia błędu od razu dowiemy się, w jakiej funkcji się znajduje oraz jaki jest scenariusz jego reprodukcji, co nieprawdopodobnie przyspiesza jego debugowanie i usunięcie.
  2. Testy są doskonałą dokumentacją kodu. Jeśli nie wiemy co dana klasa/funkcja robi oraz jak działa, możemy przejrzeć listę scenariuszy użycia funkcji/klasy i jakie będą jej wyniki. Szczególnie pomocne jest prawdiłowe (tj. niosące wiele informacji) nazewnictwo testów. Dla przykładu „test sum()” powie nam zdecydowanie mniej niż „test sum() returns 5 by summing 2 and 3”. Osobiście preferuję do tego celu BDD, ale to materiał na osobny post.
  3. Testowany kod zazwyczaj jest lepszej jakości oraz bardziej przemyślany. Programiści to zwierzęta nieprawdopodobnie leniwe. Długie funkcje i masa zależności to piekło przy testowaniu. Dla własnej wygody często rozbija się kod na mniejsze kawałeczki, przez co jest przystępniejszy i szybszy w testowaniu (mniej mockowania, mniej zagmatwanych scenariuszy, krótsze i czytelniejsze testy). Zyskujemy przy tym na łatwości utrzymania oraz przejrzystości tworzonego kodu.

 

Podsumowując, testy jednostkowe to inwestycja, która zwraca się z czasem. Zwróci się z nawiązką tym większą, im dłużej utrzymywany jest dany kod i im więcej zmian się w niego wprowadza. Umiejętność ich pisania to atut w branży programistycznej który (całe szczęście) coraz częściej jest wymagany nawet na stanowiska mid czy junior software engineer i może zaważyć na wysokości wynagrodzenia. Na zakończenie mam też kilka złotych rad, które przydadzą się podczas pracy z unit testami:

  • Zawsze mockujmy wszystkie zależności.
  • Jeśli test robi się długi, to znaczy, że testowany obiekt jest za duży i trzeba go rozbić.
  • To samo tyczy się sytuacji, gdy jest za dużo mockowania.
  • Pamiętajmy nie tylko o „happy path”, ale też o warunkach brzegowych, w których kod powinien rzucać błędem.
  • Nie duplikujmy scenariuszy pokrywających ten sam case.
  • Testujmy efekt wywołania funkcji (rezultat, triggerowanie konkretnych eventów, zmiana stanu obiektu), nie implementację (czyli nie róbmy tzw. betonowych testów).
  • Bezrozumne testowanie wyłącznie dla pokrycia nie ma najmniejszego sensu.
  • Testujmy tylko miejsca, gdzie jest jakaś logika. Testowanie getterów/setterów nie ma sensu.