Przejdź do:
Test-Driven Development
TDD to jedna z technik zwinnego wytwarzania oprogramowania. Technika Test-Driven Development, której autorem jest Kent Beck, jeden z autorów Agile Manifesto, polega na wielokrotnym powtarzaniu cyklu Red – Green – Refactor, o czym piszę poniżej. Tym, co wyróżnia technikę Test-Driven Development, jest to, że programista rozpoczyna pracę od pisania testów jeszcze nienapisanej funkcji. Pozwala także na dodawanie jakiejkolwiek nowej funkcjonalności do rozwijanej aplikacji, minimalizując ryzyko pojawienia się przypadkowych błędów w niezmodyfikowanych obszarach programu, o których powiązaniach po kilku miesiącach mogliśmy zapomnieć. Jedną z zalet Test-Driven Development jest to, że modyfikując lub dodając funkcjonalność, automatycznie otrzymujemy raporty o wszystkich powiązanych obszarach, popsutych przez nasze zmiany. Daje to szansę, by szybko je poprawić i nie dopuścić do ich wdrożenia wraz z funkcjonalnością. Podejście Test-Driven Development pozwala projektować zaawansowane wzorce i dzięki automatyzacji sprawia, że design aplikacji lub oprogramowania jest czytelny i przejrzysty. Sczególnie dobrze sprawdzają się w przypadku rozbudowanych projektów. O Test-Driven Development można by pisać dużo – w tym artykule chciałbym skupić się na stosowaniu TDD w praktyce oraz korzyściach płynących z tego podejścia.
Przeczytaj także:
- Czym jest BDD – Behavior Driven Development?
- Automatyzacja testów – obalamy mity
- Minimalistyczny przypadek testowy
Brak stosowania TDD – case study
Przed laty, gdy stawiałem pierwsze kroki jako programista, trafiłem do projektu, w którym wspólnie z kolegą po fachu mieliśmy za zadanie stworzyć dedykowany konfigurator dla producenta okien. Sam fakt złożoności technologicznej produkcji okien powinien zdeterminować maksymalną przezorność. Użytkownik aplikacji mógł skonfigurować okno, bazując na parametrach zarówno standardowych, jak i niestandardowych. W zależności od wymiarów pewne opcje były dostępne lub nie. Użytkownik miał do pokonania kilka-kilkanaście kroków z wieloma możliwościami dla każdego. Na ostateczną cenę wpływały różne opcje i czynniki.
W projekcie tym zaobserwowałem co najmniej kilka nie najlepszych praktyk, np. brak kodu testującego czy code review. Manager i klient nie wiedzieli jednak, że w miarę możliwości staraliśmy się recenzować pracę, którą dostarczaliśmy, a której efekty były przeznaczone do stosowania bezpośrednio na środowisku produkcyjnym.
Przez cały czas pracy przy projekcie czułem, że działam pod ogromną presją. Nie polemizowałem, gdy powiedziano mi, że w języku PHP (w którym pisaliśmy), nie przeprowadza się testów jednostkowych. W efekcie kod, który po modyfikacjach rozbudowywaliśmy, wymagał wielokrotnych poprawek. Gdybyśmy wówczas stosowali Test-Driven Development, udałoby się uniknąć wielu frustracji związanych z niekończącymi się poprawkami. Jak więc działają TDD?
Schemat ideowy Test-Driven Development
Schemat ideowy Test-Driven Development (Red – Green – Refactor) wydaje się wskazywać na coś bardzo prostego. Przyjrzyjmy się jego poszczególnym elementom.
Red – to nic innego jak etap pisania testów jednostkowych dla funkcjonalności, które uruchomione na aplikacji bez zaimplementowanego rozwiązania, siłą rzeczy wygenerują raport o błędach, wskazujący w najlepszym razie na niepoprawne działanie aplikacji w tym obszarze (czyli jej brak). W większości współczesnych narzędzi programistycznych do pisania kodu raport taki będzie zabarwiony charakterystycznym kolorem czerwonym. Ten etap jest bardzo istotny. Właśnie teraz developer dokonuje rozpoznania problemu, nad którego rozwiązaniem pracuje, analizując dotychczasowy kod opisujący ten obszar (jeżeli tylko taki istnieje). To również na tym etapie spoczywać będzie na jego barkach największy ciężar podczas implementacji nowej funkcjonalności.
Green – to kolejny etap, w którym czerwień z kolejnych raportów po uruchamianiu testów jednostkowych będzie w sposób progresywny przechodzić do barwy zielonej. Przejście testów będzie sygnalizowane przez IDE (Integrated Development Environment). To na tym etapie dokonywać się będzie właściwa implementacja nowej funkcjonalności.
Refactor – na tym etapie wprowadzamy poprawki do nowo zaimplementowanych funkcjonalności. Możliwie uwspólniamy zastosowane również w innych miejscach podobne rozwiązania. Dla właściwej implementacji poprawiamy czystość napisanego kodu i przygotowanych przypadków testowych. Ta faza nie oznacza końca testów, gdyż po kolejnych modyfikacjach kodu cykl Red – Green – Refactor powtarza się.
Test-Driven Development – czy można zacząć od fazy Green?
Możemy oczywiście osiągnąć podobne rezultaty, zaczynając pracę od fazy Green, poprzez Red, aż do Refactoringu. Jako że jest całkiem spora szansa na zaimplementowanie aplikacji, zanim jeszcze przygotujemy do niej zestawy testów jednostkowych, taka ścieżka wydaje się nawet kusząca. Jest jednak haczyk w takim podejściu. W ferworze walki z kolejnymi funkcjonalnościami możemy zacząć przywiązywać coraz mniejszą uwagę do jakości naszych scenariuszy testowych. Nie twierdzę co prawda, że tak musi być, ale prędzej czy później nasz kod testujący może nie pokrywać możliwych scenariuszy lub nawet stać się bezwartościowy. Rośnie wówczas ryzyko wystąpienia poważnych błędów na środowisku produkcyjnym – błędów trudnych do naprawy i najczęściej w rzadko używanych obszarach. To często skutkuje sporymi wydatkami po stronie klienta związanymi z naprawą błędów.
TDD test – zalety rozpoczynania od fazy Red
Dużo się mówi o tym, że rozpoczynając cykl realizacji funkcjonalności od przygotowania testów jednostkowych, będziemy potrzebować więcej czasu na zadanie niż w sytuacji, gdy prace zaczniemy od implementacji. To może być prawdą, gdy programista zaczyna pracę nad nowym projektem i wszystko musi przygotować od zera. Wówczas może pojawić się potrzeba utworzenia dodatkowych klas wspierających tworzenie przypadków testowych lub wdrożenia jednego z dostępnych template engine’ów do generowania requestów albo eventów. W nieco późniejszych okresach rozwoju aplikacji, jeżeli pojawią się jakieś różnice, to raczej na marginalnie niskim poziomie.
Jak minimalizować liczbę testów?
Czy nie powinniśmy tworzyć scenariuszy testowych dla każdej, nawet najmniejszej drobiny naszego kodu? Wiadomo, że najmniejsze nawet drobiny są składowymi pewnych większych struktur i zwykle jest szansa na to, by można je wspólnie opisać przy pomocy kilku rozsądnych scenariuszy przypadków brzegowych. Oczywiście zdarzają się takie obszary, jak na przykład walidatory czy kalkulatory, których często nie sposób zbadać i pokryć niewielką ilością przekrojowych testów. Chciałbym jednak podpowiedzieć, jak minimalizować liczbę testów, oszczędzając tym samym czas i wykorzystując na co dzień pełen potencjał TDD.
Testy a nieistniejące przypadki biznesowe
Ciekawym zagadnieniem jest pokrywanie testami nieistniejących przypadków biznesowych. Dylematu tego nie napotkamy zwykle podczas tworzenia testów dla walidatorów czy filtrów, ale podczas przypadku takiego jak pisanie testów z działania tworzonej funkcjonalności – już tak. Chodzi zwykle o sytuacje, które z takich czy innych powodów nie zadzieją się nigdy – jak wtedy, gdy nasza funkcjonalność zależeć będzie od danych ze ściśle określonych i zdefiniowanych zbiorów. Przykładem może być wybór kodu kraju wystąpienia szkody komunikacyjnej, gdy wykorzystujemy przeznaczoną funkcjonalność do ich rejestracji. Mając świadomość, że użytkownik będzie mógł wskazać jeden ze zdefiniowanych w bazie kodów, wiemy, że nie będzie miał on opcji wyboru innego lub nieistniejącego kodu podczas rzeczywistej pracy aplikacji. Czy w takim razie ma sens testowanie tego, jak zareaguje aplikacja, gdy użytkownik wybierze kod kraju „XXL”, jeżeli do dyspozycji zawsze będzie miał np.: tylko „PL” i „DE”?
Stosuj parametryzację
Kolejnym przykładem minimalizacji liczby testów z zachowaniem pokrycia jest parametryzacja, czyli technika konsolidowania scenariuszy, do której używania chciałbym zachęcić. Mimo że w tym podejściu nadal tworzymy tyle samo przypadków testowych, to jednak samego kodu testowego jest mniej. Z racji wielości warunków koniecznych do zbadania takie rozwiązanie sprawdzić się może podczas testowania klas walidatorów, kalkulatorów itp. Jego wadą może być spadek czytelności opisów takich testów zawartych w nazwach metod, co zwykle towarzyszyć może wszelkim aktom konsolidacyjnym. Nie będzie to jednak żadną przeszkodą ani problemem, jeśli w naszym projekcie zdecydujemy się na wykorzystanie współczesnych narzędzi, jak Spock Framework, który dostarczy nam znacznych ułatwień podczas samego pisania asercji. Ułatwienia te otrzymamy stricte z języka Groovy, na którym bazuje wspomniany framework. Język Groovy zapewnia większą przejrzystość w kontekście testów i dokumentacji. W łatwy sposób uzyskamy nazwy scenariuszy, ale też możliwość dodania dodatkowych opisów. Pozwoli to także zachować czytelną formę przypadków testowych, zgodną z „given, when, then”. Na pewno pozwoli to lepiej udokumentować nasze funkcjonalności.
Możemy niekiedy trafić na zwolenników tworzenia osobnych przypadków per scenariusz, czyli niekonsolidowania. Wówczas jednak wracamy do tematu przyszłego utrzymania wszystkich testów, gdy nasza aplikacja będzie się rozwijać. Zawsze jest coś za coś… Zwykle najrozsądniej jest jednak zachować umiar. Gdy w czasie poszukiwania złotego środka pojawi się temat rozbudowany i skomplikowany, to pomimo wielu podobieństw w przypadkach testowych moim zdaniem warto rozpisać je z osobna, jeden po drugim. Kategoryczność podejścia może doprowadzić do zagubienia użyteczności, jaką powinny wnieść testy jednostkowe (unit tests) do naszej codziennej pracy. Skupiając się zbytnio na formie, możemy zatracić rozumienie funkcji scenariuszy testowych.
Zadbaj o czytelność testów jednostkowych
Tak samo ważne jest, by skupiając się na funkcji samego przetestowania analizowanych obszarów, nie zatracić formy. Estetyka i opisowość w nazwach scenariuszy, użytych nazwach metod czy zmiennych dokumentuje nam obszary funkcjonalności i pozwala wykorzystywać test w sposób świadomy i zrozumiały. Zaryzykuję stwierdzeniem, że miarą użyteczności testu jednostkowego będzie zarówno rozsądne pokrycie testem badanego obszaru, jak i czytelność jego implementacji. Takie podejście uchroni nas przed sytuacją, której wielu mogło doświadczyć, a mianowicie kiedy po dłuższym czasie trudno jest zacząć pracę nad rozwojem dawno zaimplementowanego obszaru. Bez starannie napisanych, czytelnych testów nasza praca będzie prawdziwą drogą przez mękę.
Korzyści z TDD dla klienta i developera
TDD dla developera to przede wszystkim:
- wyższy komfort pracy – również pracy z istniejącym kodem dzięki mniejszej liczbie błędów,
- dokumentacja funkcjonalności na wysokim poziomie,
- lepsza wiedza biznesowa, a tym samym lepsza znajomość produktu,
- większa szansa na czysty kod,
- łatwiejszy start pracy w projekcie
- redukcja stresu i mniej presji w pracy,
TDD dla klienta to:
- wyższy poziom pewności co do poprawności działania aplikacji i mniej błędów,
- możliwość przewidywania kosztów utrzymania aplikacji oraz wprowadzania modyfikacji do już istniejących rozwiązań,
- oszczędności na wdrażaniu nowych programistów do pracy przy projekcie,
- szybsze lokalizowanie i naprawianie zaistniałych błędów,
- większa szansa na interesujące sugestie od developera co do wprowadzanych rozwiązań, dzięki lepszej znajomości obecnych funkcjonalności,
- TDD oznacza lepszy kod, a więc i lepszą aplikację.
Podsumowanie
Mam nadzieję, że udało mi się przekazać, co wnosi TDD do codziennej pracy i jakie mogą być konsekwencje niestosowania go w praktyce. Wspomniane przeze mnie zasady sprzyjające minimalizacji testów: parametryzacja, minimalizacja ilości, pomijanie nieistniejących przypadków biznesowych w scenariuszach oraz czytelność kodu testowego nie wynikają bezpośrednio z definicji TDD. Nie sposób jednak w praktyce osiągnąć prawdziwego rozwoju za pomocą testów bez rozsądnego ich przestrzegania. Wierzę, że udało mi się zaprezentować, czym jest metodyka Test-Driven Development i jak wielką wartość niesie ze sobą bazująca na niej produkcja dedykowanego oprogramowania.
Przeczytaj także: Clean architecture