Antywzorce unit testów

Wprowadzenie do Test Driven Development - wszystkie wpisy

W ostatnim wpisie przybliżyłem zestaw dobrych praktyk w pisaniu unit testów. Dzisiaj będę kontynuować ten temat z trochę innej perspektywy i opowiem o antywzorcach. Dzięki charakterystycznym nazwom, piętnującym konkretne złe praktyki, antywzorce zostają w pamięci i mamy je przed oczami pisząc podejrzany kod.

Podejście do testów

Pierwsza grupa wzorców nie wiąże się z pisaniem konkretnych testów, tylko raczej z nastawieniem, jakie nam towarzyszy podczas pisania i wynikającymi z tego zachowaniami.

Obywatel drugiej kategorii

Jako, że kod unit testów nie jest wykonywany w końcowym produkcie, nie jest on tak starannie utrzymywany jak kod produkcyjny. A nawet nie jest utrzymywany w ogóle. Przez to po pewnym czasie musimy mierzyć się z wszechobecną duplikacją, mylącymi nazwami i chaosem, z którego nie jesteśmy w stanie odtworzyć intencji autora. Unit testy powinny być utrzymywane z taką samą starannością, jak kod produkcyjny. Kilka minut oszczędności podczas pisania testu nie jest warte utraty wielu godzin na późniejszym doprowadzaniu testów do użytku.

Brak szacunku

Ten antywzorzec często występuje w zespołach, które dopiero zaczynają wdrażać TDD i części zespołu się to nie podoba. Manifestują to nie tylko samemu nie robiąc testów, ale również psując pracę innych. Zwykle wygląda to następująco:

  • Deweloper robi jakąś zmianę i nie aktualizuje unit testów.
  • Po wprowadzeniu system continuous integration zwraca błąd spowodowany nieprzechodzącymi testami.
  • Dev zamiast poprawić testy usuwa je, zakomentowuje albo ignoruje.

W efekcie osoba, która wcześniej napisała te testy ma fałszywe poczucie, że testy sprawdzają jakieś zachowanie. Dobrym sposobem na zapobieganie takim zachowaniom jest code review.

Konstrukcja testu

Na początku pracy zgodnie z TDD nie mamy jeszcze wystarczającej wiedzy i doświadczenia potrzebnych aby przetestować daną funkcjonalność w odpowiedni sposób. W takich wypadkach często idziemy na skróty i tworzymy testy o błędnej konstrukcji.

Optymistyczna ścieżka

Testowanie tylko podstawowego działania funkcji bez zastanowienia się nad możliwymi wyjątkami, warunkami brzegowymi, czy złośliwymi kombinacjami danych wejściowych. Jest to błąd często wynikający z braku doświadczenia lub z pośpiechu.

Poczekamy, zobaczymy

Test wykonuje początkową konfigurację, odpala testowaną funkcję, a następnie czeka określoną ilość czasu na jakieś asynchroniczne zdarzenie. Test napisany w ten sposób jest niedeterministyczny – oczekiwane zdarzenie może wykonać się w podanym czasie, ale nie musi. Poza tym testy napisane w ten sposób nie będą wykonywać się szybko. Poprawnym sposobem przetestowania zdarzeń asynchronicznych jest użycie mocków.

Łamanie zasad SOLID

Zbiór zasad SOLID został zaproponowany przez Roberta Martina i uzupełniony o dodatkowe zasady takie jak DRY (Don’t Repeat Yourself), KISS (Keep It Simple, Stupid!) i YAGNI (You Aren’t Gonna Need It). Przestrzeganie tych zasad pomaga nam tworzyć czytelny, elastyczny i łatwy do zarządzania kod. Często zapominamy, że zasady te tyczą się również kodu testów.

Gigant

Test zawierający bardzo dużo linii kodu – kilkaset, albo nawet ponad tysiąc. Test jest tak duży, że nie wiadomo do końca co robi, jego utrzymanie jest bardzo trudne i może on posiadać swoje własne bugi. Testy – giganty powstają bardzo często wraz z upływem czasu, kiedy do istniejącego testu dodajemy nowe rzeczy. Czasem istnienie takiego testu jest również sygnałem, że testowana funkcja ma zbyt dużo zależności i wykonuje zbyt wiele zadań.

Pasażer na gapę

Zamiast stworzyć nowy test, dodajemy kolejny cykl Arrange-Act-Assert do istniejącego testu. W ten sposób istniejące testy się wydłużają, a ich cel się rozmywa. W końcu taki test przeobraża się w giganta. Oczywiście postępujemy tak głównie z lenistwa. Nie chce nam się stworzyć nowego testu mimo, że to zajmuje tylko chwilę. Dodatkową wadą takiego rozwiązania jest fakt, że frameworki zwracają tylko jeden fail na test case. Czyli jeśli początkowy test wykryje błąd, dopisane później asserty nie są już sprawdzane. Może to prowadzić do sytuacji, gdzie poprawienie jednego błędu dopiero umożliwi dojście do kolejnego. Jeśli kod był by rozdzielony na dwa testy – od razu dostali byśmy dwa błędy.

Metoda Copy’ego i Paste’a

Jeden napisany test kopiujemy wiele razy i edytujemy tylko pojedyncze parametry. Robimy tak chcąc oszczędzić trochę czasu. Jednak łamiemy w ten sposób zasadę DRY i może się to zemścić np. jeśli będziemy musieli wprowadzić modyfikację do przebiegu testu. Zamiast poprawić to w jednym miejscu, musimy teraz pamiętać o wszystkich testach do których skopiowaliśmy ten kod. Dlatego właśnie w cyklu TDD jest faza refactor, żeby takie wspólne fragmenty wydzielać do oddzielnych funkcji. Należy jednak mieć w tym umiar. Z kodu testów musi być łatwo wyczytać w którym miejscu wystąpił błąd, z jakimi parametrami wywołano wtedy testowaną funkcję i jakiego wyniku się spodziewamy. Zbytnia generalizacja kodu i używanie wielu funkcji może zaciemnić obraz.

Zewnętrzne zależności

Testowany moduł zwykle wchodzi w interakcję z innymi elementami systemu. Na czas testu podstawiamy pod te inne elementy implementacje zastępcze, czyli mocki. Dzięki zastosowaniu mocków możemy kontrolować przekazywane argumenty i zwracane wartości. Należy jednak uważać, aby mocki nie obróciły się przeciwko nam.

Chain gang

Kolejne testy są od siebie zależne i muszą być wykonywane w odpowiedniej kolejności. Z tym antywzorcem często spotykamy się testując obiekty o złożonej konfiguracji lub zapamiętujące skomplikowane stany wewnętrzne. Wtedy testy często polegają na wykonaniu częściowej konfiguracji i sprawdzeniu poprawności. Kolejny test wykonuje kawałek dalszej konfiguracji i tak dalej. Usunięcie lub zmiana jednego testu z łańcucha skutkuje wtedy zepsuciem całej grupy testów.

Mockery

Test wykorzystuje tak dużo mocków, że nie jest sprawdzane pożądane zachowanie testowanej funkcji, a jedynie dane przekazywane do mocków. Takie testy często wymuszają konkretną implementację. Jeżeli testowany moduł tylko przekazuje dane z jednych modułów zależnych do innych, a sam nie posiada żadnej logiki, unit testy nie będą dodawać wiele wartości, a mocno utrudnią przyszłe zmiany. Takie moduły mogą zostać sprawdzone przez testy integracyjne.

Sobowtór

Na potrzeby testu mockujemy jakąś zależność. Jednak aby wykonać test nie wystarczy nam podstawić zwracaną wartość. Zamiast tego w mocku kopiujemy zachowanie rzeczywistej funkcji. Jeżeli będziemy chcieli wprowadzić zmiany, musimy je uwzględnić zarówno w kodzie produkcyjnym, jak i w mocku. Taka sytuacja może oznaczać, że testowana funkcja jest zbyt mocno zależna od konkretnej implementacji zależności. Możliwe też, że test próbuje za mocno wnikać w konkretną implementację. W takim wypadku wymagany jest refactor kodu produkcyjnego i testów. Alternatywą do mockowania jest po prostu zastosowanie produkcyjnej wersji zależności.

Skomplikowana konfiguracja

Aby wykonać test należy najpierw zainicjować zależności, wypełnić danymi obiekty konfiguracyjne, wywołać pewne sekwencje funkcji z odpowiednimi parametrami. Taka konfiguracja jest bardzo długa i skomplikowana. Jest podatna na błędy, wrażliwa na zmiany i ciężka w utrzymaniu. Najczęściej jest to symptom istnienia zbyt wielu zależności i realizowania przez funkcję zbyt wielu zadań.

Code coverage

Testy powinniśmy pisać nie dla słupków i cyferek w postaci code coverage, tylko aby pomagały nam w implementacji i sprawdzeniu poprawności działania aplikacji. Zbytnie zapatrzenie w procenty pokrycia kodu prowadzi jedynie do problemów.

Kłamca

Test jest tak skonstruowany, żeby wchodził w odpowiednie ścieżki w kodzie i nabijał w ten sposób code coverage. Jednak nie sprawdza on w żaden sposób poprawności wykonywania tych ścieżek. Taki test jest pisany tylko po to, by poprawić statystyki i nie niesie żadnej wartości, a wręcz przeciwnie – daje fałszywe poczucie, że kod jest testowany.

Inspektor

Aby osiągnąć lepsze pokrycie, test bazuje na konkretnej implementacji i łamie zasady enkapsulacji. W ten sposób otrzymujemy skomplikowane testy utrudniające refactor kodu w przyszłości. Pogoń za code coverage nie powinna przesłonić nam logicznej oceny sytuacji. Testowanie na siłę niektórych trudno dostępnych elementów jest stratą czasu i przyczyną problemów.

Podsumowanie

Antywzorce ze swoimi wpadającymi w pamięć nazwami są moim zdaniem świetnym sposobem nauki poprawnego pisania testów. Więcej antywzorców można znaleźć na Stack Overflow.

 

Wprowadzenie do Test Driven Development - Nawigacja

3 Comments

  1. Dobry wpis. Spotkałem się wiele razy z opisanymi tutaj antywzorcami w pracy zawodowej. Jednak poprawa sytuacji w projekcie, gdzie takie antywzorce występują to bardzo ciężki temat. Przy próbach uświadomienia zespołu, że nie do końca dobrze podchodzimy do tematu testów, często można się spotkać z brakiem zrozumienia a nawet wrogością do zmian, albo zwyczajnym ignorowaniem tematu w przeświadczeniu, że wszystko jest w porządku.

    Do samych antywzorców dodałbym jeszcze dążenie do 100% pokrycia kodu testami jednostkowymi.

    • GAndaLF

      16 września 2017 at 21:11

      No faktycznie wprowadzanie dobrych praktyk w zespole to często ciężkie zadanie. Głównym problemem jest fakt, że aby wprowadzić jakąś nową metodykę, najpierw wszyscy powinni ją poznać. Czyli najlepiej poczytać coś o tym i potrenować sobie w domu, a nie każdy może sobie na to pozwolić. Często więc takie ignorowanie i powrót do starych metod nie jest spowodowane złą wolą, tylko brakiem wiedzy.

  2. Świetny artykuł.
    Ja uczę się unit testów i widzę, że niektóre z tych antywzorców spotkam u siebie.
    Dobrze jest wiedzieć czego należy się wystrzegać i co poprawić.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *