Trwa promocja na kursy embedded

Zalety TDD

Wprowadzenie do Test Driven Development - wszystkie wpisy

Przestawienie się na Test Driven Development z pisania metodą tradycyjną nie jest łatwym zadaniem. Szczególnie na początku musimy walczyć ze starymi nawykami, a kiedy napotykamy trudności, naturalnym rozwiązaniem jest stosowanie metod, które znamy i rozumiemy. Poza tym początkowo TDD może nam się wydawać nieintuicyjne, a wkład pracy wydaje się większy. Jak to zwykle bywa w takich przypadkach, kluczem jest wytrwałość. Każda umiejętność wymaga czasu, aby ją dobrze opanować. Kiedy już nam się to uda, zauważymy, że praca w ten sposób przynosi nam pewne wymierne korzyści. W tym wpisie postaram się owe korzyści przybliżyć.

Czas życia buga

James Grenning w swojej książce „TDD for Embedded C” umieszcza następujący wykres obrazujący czas życia buga:

Programując w sposób klasyczny, o którym pisałem niedawno, najpierw piszemy większy blok kodu, a dopiero później go testujemy. W takim wypadku czas pomiędzy wprowadzeniem buga, a jego wykryciem (Td) jest duży. Może trwać kilka godzin, dni lub nawet miesięcy. Aby można go było naprawić, należy najpierw poznać jego przyczynę. Jeżeli w międzyczasie powstało dużo dodatkowego kodu, czas potrzebny na znalezienie tej przyczyny (Tfind) wydłuża się. Jeżeli dany moduł napisaliśmy już jakiś czas temu i później zajęliśmy się czym innym, często dodatkowo musimy poświęcić czas na przywrócenie kontekstu. Musimy sobie przypomnieć do czego służyły poszczególne zmienne i funkcje oraz co autor miał na myśli. Wpływa to dodatkowo na wydłużenie czasu Tfind. Możemy więc łatwo zauważyć korelację między czasami Td i Tfind. Im dłuższy Td, tym dłuższy Tfind.

Nawet bardzo skomplikowane do znalezienia bugi często są spowodowane prostymi przeoczeniami. Dlatego czas naprawy takiego buga (Tfix) często jest stały np. jeśli daliśmy odwrotnie warunek w ifie, nie ważne czy poprawimy go po godzinie czy pół roku, zawsze zajmie nam to tyle samo. Jednak czasami mamy do czynienia z bardziej subtelnymi błędami. Okazuje się wtedy, że kod, który zdążyliśmy nabudować na tym błędzie jest dodatkową przeszkodą. W takim wypadku również Tfix rośnie wraz z wzrostem Td.

Wpływ TDD na czas życia buga

Jeżeli programujemy zgodnie z TDD, zdecydowaną większość bugów testy wykryją od razu po ich powstaniu. W takim wypadku czas Td maleje praktycznie do zera. Wiedząc, że jeszcze przed chwilą kod działał, mamy bardzo mały fragment kodu, w którym należy szukać przyczyny błędu. Często od razu po przeczytaniu komunikatu o błędzie wiemy co należy poprawić. W takim wypadku Tfind również maleje do zera. Jeśli wiemy o błędzie zanim zdążymy oprzeć na nim dalszą część kodu, Tfix również będzie krótki. W efekcie otrzymujemy więc znaczną oszczędność czasu. Dodatkowym plusem TDD jest fakt, że błąd możemy znaleźć i poprawić sami. Natomiast jeżeli kod z błędem wejdzie do produkcji, często w ten proces angażujemy większą ilość ludzi (inni developerzy, testerzy, klient).

Oczywiście jest możliwość, że nawet stosując TDD jakieś błędy przemkną i będziemy musieli szukać ich po dłuższym czasie potrzebując na to dużo czasu.  Co więcej poza kodem produkcyjnym, trzeba będzie poprawić również testy. Jednak stosując TDD są całe grupy bugów, które jesteśmy w stanie dużo łatwiej wyłapywać oszczędzając czas sobie i innym.

Szybsze debugowanie

Poszukując błędów metodą tradycyjną, często korzystamy z debuggera. Wykorzystujemy go do podglądu wartości zmiennych, call stacka i przechodzenia programu krok po kroku. Taka praktyka jest bardzo czasochłonna, nie zostawia po sobie żadnego śladu – często możemy tak debugować wiele godzin, żeby poprawić jedną linię kodu – i nie pozwala ustrzec się od tego samego błędu w przyszłości.

Często na potrzeby testów tworzymy dodatkowy kod, który uruchamia testowaną funkcję z odpowiednimi argumentami, albo odpowiednio konfiguruje system. Drukujemy również wartości zmiennych oraz różne komunikaty na konsolę. Potem, gdy uznamy, że nasz kod już działa – kasujemy ten dodatkowy kod. Jeżeli okaże się, że musimy do niego wrócić, musimy napisać go od nowa.

Korzystając z TDD rzadko jesteśmy zmuszeni używać debuggera. Jeżeli potrzebujemy sprawdzić, czy zmienna przyjmuje odpowiednią wartość – piszemy test, który robi to za nas automatycznie. Jeżeli chcemy zobaczyć, czy funkcja wywołuje się tylko w konkretnych warunkach – również piszemy odpowiedni test. Kod produkcyjny powstaje przyrostowo razem z testami i wiemy, które testy odwołują się do jakich fragmentów kodu. Nie musimy dodawać tymczasowego kodu do uruchamiania pisanych funkcji w konkretnych warunkach – to wszystko robimy w testach. Testy są dużo efektywniejsze niż sprawdzanie ręczne, ponieważ wykonują się automatycznie – czyli są dużo szybsze i bardziej powtarzalne. Poza tym po zakończeniu implementacji możemy do nich wrócić.

Wartość dokumentacyjna

No właśnie – do testów możemy później wrócić i wyczytać z nich co autor miał na myśli. Poszczególne przypadki testowe obrazują, jak funkcja zachowuje się w konkretnych przypadkach – jak ją wywoływać, co otrzymamy na wyjściu, jak współpracuje z innymi modułami. Jest to pewien rodzaj dokumentacji, który nad dokumentacją papierową ma jedną ogromną zaletę. Jest to wykonywalny kod, więc tak długo jak testy się kompilują i przechodzą, jest on aktualny. Taki stan rzeczy jest praktycznie nie do osiągnięcia w dokumentacji papierowej.

Co więcej TDD może posłużyć jako narzędzie do polepszenia dokumentacji papierowej. Przed napisaniem kodu produkcyjnego zawsze musimy najpierw sformułować test, który kod ma przechodzić. Oznacza to, że wymagania muszą być jasno sprecyzowane. Pisząc dokument nie jesteśmy w stanie opisać zachowania systemu z zachowaniem tak wielkiego poziomu szczegółowości. Dlatego testy stymulują powstawanie pytań dotyczących działania systemu.

Dobry design

Aby kod dawał się przetestować, musi udostępniać na zewnątrz efekty swojego działania. Manipulując wartościami wejściowymi i analizując wartości wyjściowe powinniśmy być w stanie potwierdzić, że testowany kod zachowuje się poprawnie. Pisząc przy użyciu TDD staramy się ułatwić sobie życie wyodrębniając mniejsze, skupiające się na jednej czynności moduły. Dzięki temu powstający kod łatwiej jest przetestować, ale jest również łatwiejszy do portowania, czy zmiany implementacji. Pisząc testy jednego modułu, jesteśmy w stanie również zdefiniować interfejsy innych modułów, z którymi będzie współpracował. Te techniki wspomagają powstawanie dobrego designu aplikacji.

Musimy tu jednak uważać na pułapkę związaną ze zbytnią granulacją kodu. Kiedy rozdrobnienie modułów przekroczy pewien próg, nie są już one łatwe w utrzymaniu i nie umożliwiają szybkiego dostosowania się do zmian wymagań. Zamiast tego narzucają konkretną implementację i są trudne do zrozumienia oraz utrzymania. Niestety nie ma konkretnych reguł mówiących, kiedy granulacja jest zbyt duża. Ocena zależy od indywidualnych odczuć i doświadczenia programisty.

Jeżeli kod zawiera długie funkcje, duże powiązanie między modułami, czy skomplikowane, zagnieżdżone instrukcje warunkowe, od razu staje się trudniejszy do przetestowania. Dlatego stosując TDD świadomie staramy się unikać takich rozwiązań, a kiedy ciężko przetestować jakiś fragment, wiemy, że może to oznaczać problem z designem.

Kolejnym aspektem wpływającym na dobry design jest bezpieczny refactor. Dzięki testom mamy pewność, że wprowadzone zmiany nie zaburzą funkcjonalności. Możemy więc wykonywać je agresywniej. Poza tym w TDD refactor jest elementem każdej iteracji mikrocyklu, a nie jak to bywa w przypadku metody tradycyjnej – czynnością robioną na końcu. Dzięki temu łatwiej nam wprowadzać zmiany lepiej pamiętając kontekst oraz nie musimy modyfikować dużej ilości kodu zależnego od zrefactorowanego fragmentu.

Lepsza znajomość języka, kompilatora i bibliotek

Pisząc zgodnie z TDD bardzo często wykonujemy cykl Red – Green – Refactor. Zmusza nas to do częstej kompilacji i uruchamiania kodu. Dzięki temu szybko uczymy się, co oznaczają poszczególne komunikaty o błędach i jak objawiają się konkretne runtime errory. Poza tym pisząc unit testy mamy środowisko, w którym jesteśmy w stanie szybko otrzymać odpowiedź, czy jakieś rozwiązanie jest prawidłowe. Dzięki temu szybko uczymy się meandrów języka, kompilatora, czy wykorzystywanych biblioteki.

Bardziej stabilny przyrost kodu

Jeśli stosujemy TDD, nie ma wyraźnej granicy pomiędzy czasem na development i czasem na testy. Te dwie czynności cały czas się przeplatają, dzięki czemu kod przyrasta w sposób bardziej stabilny. Nie ma złudzenia szybszego powstawania kodu na początku, gdy piszemy samą implementację bez sprawdzenia jej działania w rzeczywistości. Nie ma również okresów testowania, kiedy ręcznie sprawdzamy czy wszystko działa i nie pozostaje po tym nic na przyszłość.

Kolejną zaletą jest możliwość lepszego określenia, kiedy praca nad danym modułem jest ukończona (definition of done). Po napisaniu modułu zgodnie z TDD jest większa szansa, że nie będziemy musieli do niego wracać, bo jakiś warunek nie został sprawdzony. Poza tym nie będzie sytuacji, że moduł został „zaimplementowany, ale nie przetestowany”.

Podsumowanie

Jak widać zalety TDD są dosyć rozległe. Od oczywistych takich jak możliwość szybszego znajdowania prostych błędów, zanim urosną one do rangi problemu, czy przyspieszenie pracy spowodowane automatyzacją niektórych czynności. Aż po rzeczy wydawałoby się zupełnie niezwiązane, jak dokumentacja i design.

Wprowadzenie do Test Driven Development - Nawigacja

2 Comments

  1. Jak dla kogoś kto nie miał podejścia praktycznego i zna słabo teorię na temat TDD artykuł jest jasny i dość szczegółowy więc duży plus 🙂

    Jako że nie mam praktyki w tym to ciężko coś wyciągnąć z tego zdania:
    „Kiedy rozdrobnienie modułów przekroczy pewien próg, nie są już one łatwe w utrzymaniu i nie umożliwiają szybkiego dostosowania się do zmian wymagań. Zamiast tego narzucają konkretną implementację i są trudne do zrozumienia oraz utrzymania. „

    • GAndaLF

      1 września 2017 at 02:00

      Chodzi o to, że pisząc zgodnie z TDD tworzysz dużo małych elementów – funkcji, czy modułów (w C to są pliki zawierające zestaw powiązanych funkcji, w językach obiektowych – klasy) wykonujących poszczególne czynności. Robisz tak dlatego, żeby uniezależnić się od konkretnych implementacji, czy jakiś zewnętrznych ograniczeń.

      Zwykle kod jest pisany jednym ciągiem i takie rozdrobnienie działa na korzyść. Zwiększa czytelność, zrozumienie, pozwala na szybszą adaptację. Jednak jeżeli przesadzimy w drugą stronę mamy ogromną ilość takich małych elementów okazuje się, że część z nich jest po prostu szczegółem implementacyjnym i jeżeli uzależnimy od tego testy, są one trudniejsze do zmiany.

Dodaj komentarz

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