W poprzednich częściach cyklu skupiałem się na korzyściach płynących z TDD. Jeżeli ta metoda wejdzie nam w krew, te korzyści zachęcą nas, abyśmy pisali w ten sposób zawsze i wszędzie. Motywują nas do tego również eksperci mówiący, że każda linia kodu powinna być przetestowana. Okazuje się jednak, że nie zawsze testowanie wszystkiego na siłę jest dobrym rozwiązaniem. W tym artykule opiszę sytuacje, kiedy nie opłaca się używać TDD.
Kod prototypowy
Programując czasem natrafiamy na problemy, co do których nie mamy z góry pewności jak należy je rozwiązać. W takiej sytuacji musimy sformułować pewne hipotezy, a następnie sprawdzić ich poprawność. Tworzymy wtedy tzw. proof of concept (PoC). Skupiamy się wtedy na jak najszybszym sprawdzeniu interesującej nas ścieżki. Nie obchodzą nas na razie przypadki graniczne, czy obsługa błędów. Poza tym nasz pomysł może okazać się nietrafiony i pójdzie do kosza. W takim wypadku TDD będzie nas tylko spowalniał i odciągał od głównego problemu.
Podczas prac nad różnymi projektami często zdarzało mi się brać udział w sesjach integracji i uruchamiania systemu. Kilku developerów pracujących nad różnymi częściami systemu spotyka się wtedy aby wspólnie połączyć te części i uruchomić system jako całość. Podczas takich integracji do kodu musiałem dodawać dużo szybkich fixów albo kod debugowy wyrzucający na zewnątrz jakieś dodatkowe informacje. Dopiero kiedy udało się dojść do zadowalającego rozwiązania mogłem wrócić do normalnego trybu pracy i zaimplementować wypracowane wcześniej pomysły w wersji produkcyjnej przy użyciu TDD dbając o refactor i obsługę błędów. Bardzo ważne jest, aby ta druga faza miała miejsce. Nie możemy dopuścić, aby kod pisany na kolanie wchodził do oficjalnej wersji, bo „w końcu działa, to po co jeszcze coś zmieniać”.
Tworzenie szkieletu aplikacji
Często na początku tworzenia systemu piszemy jedynie szczątkowy kod, który ma za zadanie pomóc nakreślić architekturę i lepiej unaocznić zależności pomiędzy poszczególnymi blokami. Powstają wtedy pierwsze wersje publicznych interfejsów, a ich implementacje często są pustymi funkcjami. W takim wypadku również nie chcemy się skupiać na szczegółach i często wprowadzamy zmiany. Na tym etapie projektu TDD jeszcze nie jest potrzebne. Powinniśmy z niego skorzystać dopiero kiedy przejdziemy do docelowej implementacji bloków.
Niektóre algorytmy
Implementując algorytmy kryptograficzne takie jak np. AES, czy MD5, albo nawet CRC ciężko nam skorzystać z TDD. Jedyne co możemy zrobić to napisać testy podające jakieś ciągi danych na wejściu i spodziewające się konkretnej wartości wyjściowej. Implementacja takiego algorytmu nie będzie rosła iteracyjnie wraz z kolejnymi testami. Aby zwracać poprawną wartość po prostu musimy zaimplementować cały algorytm. TDD może nam pomóc co najwyżej w sprawdzaniu błędów typu NULL pointer.
Podobnie, a nawet gorzej, ma się sprawa z algorytmami numerycznymi. Podobnie jak poprzednio występuje tu problem z iteracyjnym przyrostem kodu wraz z testami. Co więcej takie algorytmy mogą mieć na przykład czasy ustalania sięgające wielu iteracji, które są zależne od danych wejściowych. Nie możemy więc napisać prostego asserta walidującego działanie algorytmu. Poza tym nawet dobrze napisany kod może przyjąć parametry poprawne z logicznego punktu widzenia, które jednak powodują, że algorytm „wybucha” .
Drivery hardware’owe
Z testowaniem obsługi hardware wiąże się kilka problemów. Po pierwsze unit test sprawdzi tylko, czy wpisaliśmy odpowiednie wartości do konkretnych rejestrów. Nas jednak bardziej interesuje, czy wpisanie tych wartości spowoduje konkretne działanie sprzętu, a to możemy sprawdzić tylko na docelowej platformie.
Po drugie samo sprawdzanie wartości w rejestrach jest problematyczne. Zwykle w bibliotekach procesora mamy definicje wskaźników zmapowanych na konkretne adresy rejestrów w pamięci. Na czas testów możemy próbować podmienić te nagłówki. Chociaż może to być skomplikowane, jeśli są to nagłówki kompilatora. Nie zawsze jednak będziemy w stanie odtworzyć zachowanie tych rejestrów hardware. Czasem wpisanie jedynki na jakimś bicie powoduje wyzerowanie innego, albo informacja z rejestru znika po pierwszym odczycie. Dlatego po prostu lepiej testować drivery na sprzęcie.
Programowanie defensywne
Pisząc kod spełniający normy bezpieczeństwa (np. SIL) należy stosować zasady defensive programming. Chodzi o to, aby nawet jeśli na skutek jakiejś awarii np. program skoczy w jakieś dziwne miejsce, albo zmienne zostaną zmodyfikowane, żeby system sobie z tym poradził. Aby zabezpieczyć się na takie ewentualności, dodaje się trochę nadmiarowego kodu np. aby switch zawsze miał obsługę default, a else if zawsze kończył się else nawet jeżeli wszystkie możliwości są pokryte wcześniej. Przez to w kodzie tworzą się obszary do których po prostu nie da się dostać, kiedy program działa poprawnie. Nie da się więc sprawdzić ich w unit teście. Mają one chronić system w sytuacjach ekstremalnych.
Interfejs użytkownika
Interfejs użytkownika – graficzne ułożenie elementów na stronie, czy w aplikacji nie powinno być przedmiotem unit testów. Takie elementy powinny zawierać jak najmniej logiki – najlepiej tylko wywołanie jednej funkcji obsługującej żądanie – ta funkcja już jest częścią innego modułu, który powinien zawierać unit testy.
Kiedy testy są czasochłonne i nie przynoszą dużo korzyści
Przykładem kodu, który jest trudny do przetestowania, a pewnie i tak nie zrobimy tego dobrze są wspomniane wrześniej drivery sprzętowe. Po drugiej stronie skali znajduje się kod tak trywialny, że naprawdę szkoda czasu, aby pisać do niego testy koronnym przykładem są tutaj gettery i settery. Często mamy również do czynienia z kodem służącym jako pośrednik między różnymi modułami. Jego zadaniem jest odbieranie danych z jednego bloku i wysyłanie go do drugiego. Taki kod jest często bardzo prosty, natomiast jego testowanie może być prawdziwą udręką ze względu na ogromną ilość zależności, a co za tym idzie potrzebę zrobienia wielu mocków. Tego typu testy są bardzo niewdzięczne zarówno do pisania, jak i do utrzymywania, ponieważ są mało elastyczne na zmiany implementacji. Testy takich modułów mają swoją wartość – mogą wyłapać, że jakiś parametr źle przekazujemy (częstym błędem jest tu copy paste), albo nie wywołujemy jakiejś funkcji. Jednak możemy je spokojnie dopisać później. W przypadku takich modułówdesign, ani czystość kodu raczej nie polepszy się dzięki użyciu TDD.
Zewnętrzne biblioteki i kod generowany automatycznie
Testowanie bibliotek i wygenerowanego kodu nie jest odpowiedzialnością naszą, tylko jego dostawcy. Nie powinniśmy więc tracić czasu na jego unit testowanie. Jeżeli nie mamy pewności do zachowania takiego kodu możemy najwyżej zrobić testy akceptacyjne takiej biblioteki. Będą one sprawdzać funkcjonalności, których używamy w naszym kodzie. Może to być pomocne przy podjęciu decyzji o przejściu na nową wersję. Dostaniemy wtedy od razu feedback, czy interesująca nas funkcjonalność działa bez zmian.
Code coverage
Testów nie piszemy po to, żeby osiągnąć jakąś konkretną wartość pokrycia. Code coverage jest efektem ubocznym dobrze napisanego i przetestowanego kodu. Mając pokrycie rzędu 90% na każdy kolejny procent potrzebujemy tyle czasu, co wcześniej na 20%. Za tym kosztem czasu nie idą zwykle wymierne korzyści – system nie staje się od tego bardziej niezawodny.
Podsumowanie
Jak łatwo się domyślić, wyczucie kiedy nie należy stosować unit testów przychodzi z doświadczeniem. Możemy ocenić jaka jest szansa, że w danym kodzie popełnimy błąd, jakie może on mieć skutki, ile czasu zajmą nam unit testy i na ile zmniejszą one ryzyko błędu. Polecam tutaj artykuł twórcy Ruby – Davida Heinemeiera Hanssona, w którym tłumaczy co dało mu TDD i dlaczego już go nie stosuje. Jeżeli jednak nie jesteśmy takimi ekspertami programowania musimy być bardzo ostrożni, kiedy decydujemy się pominąć testy. Łatwo znajdować sobie różne wymówki, żeby nie testować i zanim się zorientujemy, możemy wrócić do pisania metodą code and fix.
7 września 2017 at 21:51
Zaje….a seria wpisów na blogu. Dzięki wielkie za podzielenie się wiedzą praktyczną.