TDD czyli Test Driven Development to bez wątpienia najbardziej znana praktyka ze zbioru XP[1]. Sama koncepcja już leciwa, a jej pomysłodawca - Kent Beck - przedstawił jej fundamenty w książce z 2002 roku[2]. W telegraficznym skrócie można by rzecz, że sprowadza się do napisania testu, który weryfikuje poprawność metody czy funkcji na podstawie przyjętych założeń, jeszcze przed faktyczną implementacją. W tym wpisie jednak nie poruszę tego jak TDD działa, a opisze swoje wzloty i upadki podczas jej stosowania.

Technika test first wyzwala bardzo skrajne emocje u różnych programistów. Jedni powiedzą, że to zupełna bzdura i strata czasu, a drudzy, że brak stosowania TDD jest oznaką braku profesjonalizmu. Tych po środku jest niestety bardzo niewiele. Zacznijmy od argumentów przedstawianych przez zwolenników.

Korzyści wynikające ze stosowania TDD

  • Redukcja stresu - Autor kultowej książki TDD By Example [2] zapewnia, że stosownie podejścia “najpierw test potem implementacja” wpływa pozytywnie na poczucie bezpieczeństwa programisty. Dzieje się tak ponieważ programista weryfikuje zgodność implementacji z założeniami, które zostały przyjęte w asercjach. Ponadto, wysokie pokrycie kodu testami pozwala na bezpieczne refactory i modyfikację logiki aplikacji. Każdą wprowadzaną zmianię można sprawdzić pod kątem zgodności z wcześniej przyjętymi kryteriami akceptacji i zweryfikować czy nic “się nie popsuło”.
  • Zwięzły i łatwo testowalny kod - Innym aspektem, dotyczącym już samego kodu jest powstawanie mniejszych oraz lepiej podzielonych klas czy funkcji. Całkiem zabawne, że to efekt uboczny naszego lenistwa. Mniejsze kawałki kodu, które są pozbawione pokaźnego zbioru zależności prościej się testuje. Sprzyja to powstawaniu bardziej wyspecjalizowanych obiektów z niewielką liczbą odpowiedzialności.
  • Redukcja duplikacji - Przy założeniu, że postępujemy zgodnie z zaleceniami autora, to w znacznym stopniu redukujemy duplikacje poprzez dążenie do generalizacji zachowań i dzielenie kodu na coraz mniejsze, reużywalne kawałki.
  • Darmowa dokumentacja - Jedyna użyteczna dokumentacja to ta aktualna. Najbardziej aktualny jest kod programu, więc wysokie pokrycie testami zapewnia też dokumentację oczekiwanych zachowań oraz sposobu korzystania z publicznych interfejsów. A sama dokumentacja pozostaje aktualna tak długo, jak długo pojawia się “zielony pasek”

Ale czy na pewno?

Druga strona barykady powie, że to zwykła strata czasu i pieniędzy firmy. Muszę się przyznać, że zdarzyło mi się do niej w pewnym momencie swojej kariery dołączyć. Na krótki okres porzuciłem dobre praktyki, bo mocno się sparzyłem. Na własnej skórze poczułem jak bolesne mogą być refactory albo wprowadzanie rozległych zmian w aplikacji, gdy wszystko powstało jako test first. Stosunkowo niewielka zmiana albo jak to mawiał Kent Beck mały krok, potrafi wysadzić kilka czy nawet kilkanaście testów jednostkowych lub popsuć kompilację projektu. Redukcja stresu? Nie dość, że stres się zwiększył, to dodatkowo pojawiła się też frustracja i powątpiewanie w zasadność stosowania tej techniki. Przecież jak wiadomo, postępując w rozsądny sposób nigdy nie powinno się w jednym cyklu modyfikować zarówno testów i implementacji. A tu klops. Bezpieczeństwo okazało się być tylko iluzją.

Co więc poszło nie tak?

Porzucenie TDD na krótką chwilę dało mi czas na przemyślenia swoich błędów. Przeanalizowałem koncept jeszcze raz, poszukałem informacji na blogach, usystematyzowałem wiedzę i uświadomiłem sobie dlaczego opisana sytuacja w ogóle miała miejsce. Sądzę, że nie do końca dobrze zrozumiałem koncepcję. Spektakularna porażka była wypadkową trzech błędów:

  • Brak modelu. Naiwnie myślałem, że TDD zagwarantuje dobry design całej aplikacji oraz jej elementów składowych. Pisałem kolejne testy i byłem zadowolony, że to co powstaje jest małe, łatwo testowalne i zgodne z narzuconymi przeze mnie wymaganiami funkcjonalnymi. W tamtym momencie nie zastanawiałem się szczególnie jak nowy kawałek wpasowuje się w domenę. Kiepski model zabetonowałem pokaźną liczbą testów, przez co model stał się nie do ruszenia. Niestety, jak się później okazało, komponenty, które w ten sposób powstały, miały błędnie wyznaczone granice i odpowiedzialności. Nadejście momentu zmian w aplikacji spowodowało, że cały design rozpadał się na drobne kawałki, a większość testów wylądowała w koszu. Smutna prawda jest taka, że TDD to Test Driven Development, nie Test Driven Design. TDD nie spowoduje, że aplikacja będzie lepiej zaprojektowana. Pozwoli napisać lepszą implementację ale na kiepskie projekt modelu nic nie poradzi.
  • Testowanie implementacji, a nie abstrakcji Dość oczywiste wydaje się stwierdzenie, że powinno się testować abstrakcję, a nie implementację. Powszechnie wiadomo, że jedyną pewną rzeczą jest zmiana “sztywnych reguł” i “niezmienników”. Niestety, łatwiej powiedzieć niż zrobić. W dość nieświadomy sposób przywiązywałem się do konkretnej implementacji klas. Krótkowzrocznie patrzyłem co wywołanie danej metody zmieni w tej konkretnej klasie, zamiast pomyśleć co dana metoda spowoduje w kontekście całego modułu. Ciężko jest to wytłumaczyć, dlatego spróbuję pokazać to na przykładzie. Wyobraźmy sobie, że piszemy sklep internetowy. Biznes dał nam wymaganie, aby przy rejestracji ustawiać datę urodzenia klienta. Aby zweryfikować czy dobrze jest to zaimplementowane, można napisać test, który sprawdzi czy properta BirthdayDate jest równa wartości przekazanej do metody, która jest testowana. Oznacza to, że BirthdayDate musi być możliwe do odczytania bezpośrednio z zewnątrz obiektu. Można zacząć też od innej strony. Po co właściwie ta data urodzenia? Po rozmowie z biznesem okazuje się, że użytkownicy, w dniu swoich urodzin dostają specjalną zniżkę, dajmy 10%. Wcale nie chodzi o datę urodzenia, ale o sprawdzenie czy użytkownik ma dzisiaj urodziny. Zmienia to zupełnie postać rzeczy i otwiera nowe możliwości. Ze zdobytą wiedzą można napisać inny test, który nie wprost zweryfikuje czy użytkownik ma urodziny. Można sprawdzić, czy naliczona zostanie zniżka urodzinowa w wysokości 10%. Taki test wymaga znacznie głębszego zrozumienia systemu, ale pozwala na dowolną zmianę szczegółów implementacyjnych reprezentacji użytkownika, przy zachowaniu zdolności do odpowiedzi na pytanie czy użytkownik ma dzisiaj urodziny. Dużo trudniej jest taki test napisać, ale warto się przekonać samemu, że warto.
  • Brak architektury testów Ostatni problem, który zaobserwowałem to odwzorowywanie struktury aplikacji w strukturze testów. Niestety jest to powszechny problem, który wynika z przykładów dostępnych w internecie oraz literaturze. Pozwalają one sądzić, że to całkiem poprawne podejście. Zobrazuję to przykładem. Dużo źródeł zachęca do tworzenia klasy Foo oraz utworzenia dla niej klasy testowej FooTests. Klasycznego odwzorowania one-to-one. Choć na pierwszy rzut oka jest co całkiem okej, to długofalowo powoduje naprawdę dużo problemów. Rozważmy sytuację, że wspomniana klasa Foo zostaje poddana refaktoryzacji w skutek czego zostaje podzielona. Część metod przeniesiona jest do klasy Bar, część zmieniona, a kilka metod zostaje usuniętych. Wszystkie klasy testowe, które korzystały z klasy Foo muszą zostać zmienione. Przy dwóch klasach to relatywnie mały problem. Rozpacz nastaje, gdy okaże się, że w ramach jednego refactoru takich klas jest naście. Rozwiązaniem problemu jest testowanie abstrkacji, interfejsu, który klasa Foo implementuje. Przy tym podejściu, klasa FooTests zapewne nawet nie powstanie. Jej miejsce zajmie test konkretnego use-case’a. Sama metoda klasy Foo byłaby wywołana niejawnie poprzez ten interfejs, ale nie zmienia to faktu, że zostałaby pokryta testami. Po raz kolejny widać jak ważnym elementem jest tu model i abstrakcja. Polecam zapoznać się z genialnym artykułem[3] Uncle Boba, który w bardziej szczegółowy sposób opisuje ten problem.

Morał z moich porażek.

Dojście do takich wniosków zajęło mi znacznie więcej czasu niż powinno. Choć teraz te aspekty wydają mi się całkiem oczywiste to kiedyś zupełnie nie zdawałem sobie z nich sprawy i tego jak łatwo wpaść w te pułapki. Niewielkie zmiany sposobu wykorzystywania tej techniki pozwalają na osiągnięcie znacznie lepszych rezultatów. Dwie rady na koniec:

  • Analiza wymagań i przygotowanie modelu - zawsze gdy piszemy faktyczną implementację kawałka, który ma trafić na produkcję trzeba dobrze przeanalizować wymagania biznesowe i skonfrontować je z obecnym modelem domeny. Pojedyncze helperki nie wymagają szczególnego designu, natomiast dla złożonych domen jest on wręcz punktem krytycznym. Szansa, że w trakcie kodowania powstanie sensowny model jest naprawdę niewielka. A słaby model to testy związane z implementacją. Dobrze się to nie skończy.
  • Przygotowanie listy testów na podstawie kryteriów akceptacji - przeprowadzona analiza pozwala na przygotowanie listy testów (w TDD By Example[2] autor pokazuje taką technikę) co uchroni przed zbędnym testowaniem szczegółów implementacyjnych. Pozwoli to skupić się na faktycznych wymaganiach. Poprawnie przygotowana lista pozwala na utworzenie struktury testów, która odzwierciedla problemy, które należy zweryfikować, zamiast implementacji utworzonych klas.

Warto też pamiętać, że TDD jak każda inna technika czasem pasuje, a czasem tylko utrudnia pracę. Gdy piszmy kod produkcyjny to TDD jest jak najbardziej wartościowe i warto zainwestować w to trochę czasu. Jednak gdy prototypujemy czy robimy PoC’a to może się okazać, że TDD tylko przeszkadza. Tego typu moduły mają to do siebie, że wymagania się zmieniają diametralnie i to bardzo szybko. Z doświadczenia wiem, że nie warto wtedy iść w TDD. Ten kod i tak zaraz zostanie zastąpiony czymś lepszym.

[1] Kent Beck, Extreme Programming Explained: Embrace Change. ISBN 978-0321278654 (2004 ed.)
[2] Kent Beck, Test Driven Development: By Example. ISBN 978-0321146533
[3] Robert C. Martin (Uncle Bob), [artykuł] TDD Harms Architecture. Źródło: https://blog.cleancoder.com/uncle-bob/2017/03/03/TDD-Harms-Architecture.html \

Tagi: ,

Kategorie:

Ostatnia aktualizacja:

Zostaw komentarz