Pisanie kodu testowalnego

Opublikowany: 2022-11-03

Testowanie jednostkowe jest niezbędnym instrumentem w zestawie narzędzi każdego programisty. Pisanie testów jednostkowych jest stosunkowo łatwe, gdy mamy do czynienia z bazą kodu, która jest zgodna z najlepszymi praktykami, wzorcami i zasadami projektowania oprogramowania. Prawdziwy problem pojawia się podczas próby jednostkowego testowania źle zaprojektowanego, nietestowalnego kodu.

W tym blogu omówimy, jak napisać więcej kodu, który można testować jednostkowo, oraz jakich wzorców i złych praktyk należy unikać, aby poprawić testowalność.

Testowalny i nietestowalny kod

Pracując nad aplikacjami na dużą skalę, które wymagają utrzymania na dłuższą metę, musimy polegać na automatycznych testach, aby utrzymać wysoką ogólną jakość systemu. W porównaniu z testami integracyjnymi, w których testujesz wiele jednostek jako całość, testy jednostkowe mają tę zaletę, że są szybkie i stabilne. Szybko, ponieważ tworzymy instancje, najlepiej tylko testowaną klasę, i stabilnie, ponieważ zwykle wyśmiewamy zewnętrzne zależności, np. bazę danych lub połączenie sieciowe.

Jeśli nie znasz dokładnej różnicy między testami jednostkowymi a testami integracyjnymi, możesz przeczytać więcej na ten temat w naszym blogu Wprowadzenie do testowania.

Testowalny kod można odizolować od reszty naszego kodu. Innymi słowy, najmniejsze jednostki mogą być testowane niezależnie. Kod nietestowalny jest napisany w taki sposób, że napisanie dla niego dobrego testu jednostkowego jest trudne lub wręcz niemożliwe.

Przyjrzyjmy się kilku antywzorcom i złym praktykom, których powinniśmy unikać podczas pisania testowalnego kodu.

Przykłady są napisane w Javie, ale wymienione tutaj konwencje kodowania mają zastosowanie do każdego zorientowanego obiektowo języka programowania i frameworka testowego. W tym poście na blogu użyjemy asertj i JUnit5.

Wstrzykiwanie zależności

Wstrzykiwanie zależności jest jednym z najważniejszych wzorców projektowych umożliwiających uzyskanie izolacji testowej. Wstrzykiwanie zależności to wzorzec projektowy, w którym jeden obiekt otrzymuje inne obiekty (zależności) poprzez parametry konstruktora lub settery zamiast konieczności ich samodzielnego konstruowania.

Dzięki wstrzykiwaniu zależności możemy łatwo wyizolować testowaną klasę, wyśmiewając zależności obiektu.

Spójrzmy na przykład bez wstrzykiwania zależności:

Ponieważ zależność silnika jest budowana w konstruktorze klasy Car, można powiedzieć, że klasy Car i Engine są ściśle powiązane. Są od siebie w dużym stopniu zależne – zmiana jednego wymagałaby zmiany drugiego.

Z perspektywy testowania nie można testować klasy Car w izolacji, ponieważ powyższy przykład nie może zastąpić konkretnej implementacji silnika dubletem testowym.

Możemy jednak osiągnąć izolację za pomocą wstrzykiwania zależności i polimorfizmu:

Teraz możemy konstruować wiele implementacji silnika, a tym samym samochody z różnymi silnikami:

Testowanie w izolacji jest teraz możliwe, ponieważ możemy stworzyć mockującą implementację abstrakcji Engine i przekazać ją do naszej klasy Car:

Mając do czynienia z obiektami, które wymagają innych obiektów (zależności), należy podać je poprzez parametry konstruktora (dependency injection), najlepiej ukryte za pewną abstrakcją.

Postępując zgodnie z tym wzorcem, Twój kod staje się bardziej czytelny i można go dostosowywać do zmian w czasie. Ponadto należy unikać wykonywania rzeczywistej pracy w konstruktorach – wszystko więcej niż przypisania do pól to rzeczywista praca. Słowo kluczowe `new` w konstruktorach jest zawsze znakiem ostrzegawczym przed nietestowalnym kodem.

Należy zauważyć, że w niektórych sytuacjach ścisłe sprzężenie (np. nowe słowa kluczowe w konstruktorach, klasy wewnętrzne do izolacji logiki, mapery obiektów) nie jest złą praktyką – klasy, które jako „samodzielne” klasy nie miałyby sensu.

Stan globalny

Udostępnianie globalnego stanu może często generować niestabilne testy (czasami kończą się pomyślnie, czasami zawodzą), zwłaszcza w środowiskach wielowątkowych.

Wyobraź sobie scenariusz, w którym wiele testowanych obiektów ma ten sam stan globalny — jeśli metoda w jednym z obiektów wywoła efekt uboczny, który zmieni wartość współdzielonego stanu globalnego, dane wyjściowe metody w innym obiekcie stają się nieprzewidywalne. Unikaj używania nieczystych metod statycznych, ponieważ mutują one w pewien sposób stan globalny lub są proxy do jakiegoś stanu globalnego.

Spójrzmy na tę nieczystą metodę statyczną:

Zasadniczo ta metoda odczytuje bieżącą datę i godzinę systemową i zwraca wynik na podstawie tej wartości. Byłoby bardzo trudno napisać właściwy test jednostkowy oparty na stanie dla tej metody, ponieważ wywołanie statyczne LocalDateTime.now() będzie generować różne wyniki podczas wykonywania naszych testów. Pisanie testów dla tej metody jest niemożliwe bez zmiany daty i czasu systemowego.

Aby to naprawić, przekażemy metodę daty i godziny do metody timeOfDay jako argument:

Metoda statyczna timeOfDay jest teraz czysta — te same dane wejściowe zawsze dają te same wyniki. Teraz możemy łatwo przekazać izolowane obiekty dateTime jako argumenty w naszych testach:

Prawo Demeter

Prawo Demeter, czyli zasada najmniejszej wiedzy, mówi, że przedmioty powinny wiedzieć tylko o przedmiotach blisko spokrewnionych z pierwszym obiektem. Innymi słowy, jeden obiekt powinien mieć dostęp tylko do tych obiektów, których potrzebuje. Na przykład mamy metodę, która przyjmuje jako argument obiekt kontekstu:

Ta metoda narusza prawo Demeter, ponieważ musi poruszać się po grafie obiektu, aby uzyskać informacje wymagane do wykonania swojej pracy. Przekazywanie niepotrzebnych informacji do klas i metod szkodzi testowalności.

Wyobraź sobie ogromny obiekt BillingContext zawierający odniesienia do innych obiektów:

Jak widać, nasz test jest przepełniony nieistotnymi informacjami. Testy tworzące złożone wykresy obiektów są trudne do odczytania i wprowadzają niepotrzebną złożoność.

Poprawmy nasz poprzedni przykład:

Zawsze powinieneś przekazywać bezpośrednie zależności do swoich klas i metod. Jednak przekazywanie wielu argumentów do metod również nie jest dobrą praktyką — najlepiej byłoby przekazać dwa argumenty na maksimum lub zawinąć blisko powiązane argumenty w obiekty danych.

Przedmioty Boga

Obiekt Boga to obiekt, który odwołuje się do wielu innych odrębnych obiektów, ma więcej niż jedną odpowiedzialność i ma wiele powodów do zmiany. Jeśli podsumowanie tego, co robi klasa, jest trudne lub jeśli podsumowanie zawiera słowo „i”, klasa prawdopodobnie ma więcej niż jedną odpowiedzialność.

Obiekty Boga są trudne do przetestowania, ponieważ mamy do czynienia z wieloma niepowiązanymi zależnościami, mieszając różne poziomy abstrakcji i obaw, i wywołują one wiele skutków ubocznych. W związku z tym trudno jest osiągnąć pożądany stan w naszych przypadkach testowych.

Na przykład:

UserService ma więcej niż jeden obowiązek – rejestrowanie nowych użytkowników i wysyłanie e-maili. Testując rejestrację użytkownika, mamy do czynienia z obsługą poczty e-mail i odwrotnie:

Wyobraź sobie usługę UserService z więcej niż dwiema niepowiązanymi zależnościami. Te zależności mają swoje własne zależności i tak dalej. Otrzymalibyśmy test, który byłby nieczytelny, wypełniony niepowiązanymi informacjami i bardzo trudny do zrozumienia. Dlatego każda klasa powinna mieć tylko jedną odpowiedzialność i powód do zmiany. Klasa mająca tylko jeden powód do zmiany to jedna z pięciu zasad projektowania oprogramowania zwanych zasadą pojedynczej odpowiedzialności.

Więcej o zasadach SOLID przeczytasz tutaj.

Wniosek

Baza kodu, która jest zgodna z najlepszymi praktykami projektowania oprogramowania, znacznie ułatwia pisanie testów jednostkowych.

Z drugiej strony napisanie działającego testu jednostkowego dla bazy kodu przy użyciu wspomnianych antywzorców może być bardzo trudne, a czasem wręcz niemożliwe. Pisanie dobrego testowalnego kodu wymaga dużo praktyki, dyscypliny i dodatkowego wysiłku. Najważniejszą zaletą testowalnego kodu jest łatwość testowalności oraz możliwość zrozumienia, utrzymania i rozszerzania tego kodu.

Mamy nadzieję, że ten blog pomoże Ci napisać testowalny kod.