Czym jest Test Driven Development? Wprowadzenie, przykłady

Czym jest Test Driven Development? Wprowadzenie, przykłady

Artykuł ten jest krótkim wprowadzeniem do Test Driven Development, dzięki któremu każdy może poznać podstawy i prześledzić przykłady pisania kodu zgodnie z tą techniką.

TDD docenią szczególnie osoby, które mają już dosyć mozolnego szukania błędów w programie i chciałyby przejść na trochę wyższy poziom.

W tym wpisie przedstawię Wam technikę, która zupełnie zmieniła sposób w jaki piszę oprogramowanie. Pozawala mi ona ograniczyć do niezbędnego minimum testowanie i debugowanie na sprzęcie. Poza tym, technika ta, zmienia nasz sposób myślenia każąc najpierw zastanowić się nad wymaganiami, przez co zwiększa się nasza świadomość na temat rzeczy, które mogą pójść źle.

Chodzi o Test Driven Development (TDD), czyli technikę wykorzystującą testy automatyczne. Zanim zaczniesz się jej uczyć, musisz zdobyć trochę biegłości w programowaniu w klasyczny sposób, jednak na pewno warto poświęcić później czas na naukę tej techniki - wspominałem już o tym w poprzednim artykule: 3 różnice w programowaniu: hobbystycznie vs. komercyjnie.

Jeśli jesteś bardzo początkującym programistą to nie musisz od razu zaczynać od poznawania TDD, bo może to być dla Ciebie zbyt trudne. W takim wypadku lepiej skupić się na nauce kodowania bez popełniania podstawowych błędów. Pomoże Ci w tym poniższy artykuł:

Jak unikać pułapek języka C?
Jak unikać pułapek języka C?

Dobry programista powinien wiedzieć jakie aspekty języka są niebezpieczne i… Czytaj dalej »

Naturalne podejście do programowania

Zwykle nasze projekty rozwijamy z wyraźnym rozdzieleniem fazy pisania kodu i debugowania. Metoda ta jest czasem nazywana Code & Fix, albo Debug Later Programming. Polega ona na tym, że najpierw przez pewien czas piszemy, a potem próbujemy to uruchomić i sprawdzamy czy działa tak jak powinno. Czas mijający do uruchomienia programu bywa bardzo długi. Jeżeli więc popełnimy jakiś błąd, zdąży on dojrzeć i odpowiednio się ukryć, przez co potem musimy stracić sporo czasu, aby go znaleźć.

Dobrze obrazuje to wykres czasu życia buga przedstawiony przez Jamesa Grenninga w książce Test Driven Development for Embedded C:

Czas życia błędu

Czas życia błędu

Im dłuższy czas Td od wprowadzenia buga do jego wykrycia, tym dłuższy czas Tfind potrzebny na odkrycie przyczyny problemu. Kiedy już znamy tę przyczynę, Tfix czyli czas naprawy jest zwykle krótki. Jednak jeżeli błąd znajdziemy bardzo późno, możemy już mieć dużo zależności nabudowanych na tym błędzie. Jeżeli rozwijamy projekt komercyjny, czas życia buga jest proporcjonalny do kosztów. Tym bardziej, że im więcej czasu mija, tym większa szansa, że zaangażujemy w problem więcej osób: innych developerów, testerów, a nawet użytkowników końcowych.

Problemy typowe dla systemów embedded

Problemem, o którym zawsze trzeba pamiętać w systemach embedded jest sprzęt. Zwykle debugujemy bezpośrednio używając sprzętowego debugera. Wgrywanie kodu na docelowy procesor trwa długo i czasem kończy się błędem. Nie wszystkie błędy łatwo jest odtworzyć przechodząc ręcznie przez kolejne linijki kodu w debugerze, dlatego testowanie w ten sposób jest nieefektywne.

Nikomu nie chce się też powtarzać wszystkich sprawdzeń po każdej zmianie. Możliwości sprzętowych debugerów są również mniejsze niż w przypadku tych na PC (rozbudowane sondy do debugowania są drogie). Poza tym system może być wielozadaniowy co dodatkowo utrudnia debugowanie.

Na szczęście nie musimy wszystkiego debugować na docelowym sprzęcie. Jest część, przy której od tego nie uciekniemy np. sterowniki peryferiów. Jednak cała logika mogłaby być z powodzeniem uruchamiana i testowania na komputerze. Nie od dziś wiadomo, że najbardziej efektywnym sposobem testowania jest automatyzacja. Dzięki temu mamy powtarzalne testy, które wykonują się szybko i bez angażowania uwagi programistów.

Czym jest Test Driven Development?

Test Driven Development to technika programowania odwracająca naturalną kolejność. Najpierw piszemy test definiujący wymagania stawiane naszemu programowi, a dopiero potem dopisujemy implementację przechodzącą ten test. Cały cykl dzielimy na krótkie iteracje, gdzie najpierw piszemy jeden test, potem dodajemy kod przechodzący wszystkie testy, a następnie poprawiamy strukturę kodu tak, aby testy dalej przechodziły.

Mikrocykl TDD, czyli Red-Green-Refactor

Mikrocykl TDD, czyli Red-Green-Refactor

TDD - czym jest faza Red?

W fazie Red dodajemy tylko jeden test, który nie przechodzi i w ten sposób pokazuje jakiś brakujący element naszej implementacji. Szczególnie na początku może to być nieintuicyjne, ponieważ możemy łatwo wskazać wiele niespełnionych wymagań.

Faza Red zmusza nas do przemyślenia wymagań zanim rzucimy się w wir kodowania. Dzięki temu nasz system będzie lepiej przemyślany. Czasem od razu wychwycimy jakieś niespójności i będziemy mogli doprecyzować działanie systemu zanim jeszcze napiszemy pierwszą linijkę kodu produkcyjnego.

TDD - czym jest faza Green?

W fazie Green dodajemy minimalną ilość kodu produkcyjnego potrzebną do przejścia testów. Tutaj również ręce będą nas świerzbić, aby od razu napisać ostateczną implementację. Jednak musimy się od tego powstrzymać i napisać tylko tyle ile wymagają od nas istniejące testy.

Czasem prowadzi to początkowo do absurdalnej implementacji, ale to nic. Ostatecznie testy wymuszą na nas dobrą implementację. Dzięki zachowaniu takiego reżimu mamy potem cały kod pokryty testami i w przypadku jakiejś zmiany wiemy, że nic nie zepsuliśmy.

TDD - czym jest faza Refactor?

W fazie Refactor poprawiamy napisany kod nie zmieniając jego funkcjonalności. Gwarantują nam to istniejące testy, które zawsze możemy uruchomić i sprawdzić, że nic nie zepsuliśmy. Zwykle nie mając testów unikamy refaktoru lub robimy tylko kosmetyczne zmiany typu poprawienie nazw zmiennych.

To tyle teorii, pora na przykład praktyczny!

Przykładowy kod – bufor cykliczny

Jako przykład napiszemy sobie w TDD bufor cykliczny. Jest to struktura danych często wykorzystywana w embedded na przykład do komunikacji lub do składowania próbek pomiarowych. Pewnie większość czytelników wie jak on działa, jednak dla pewności zacznę od krótkiego opisu.

Bufor cykliczny składa się z tablicy o pewnym z góry ustalonym, maksymalnym rozmiarze oraz ze zmiennych przechowujących dwa indeksy – startowy, często nazywany głową (head), oraz końcowy, często nazywany ogonem (tail).

Głowa wskazuje pierwszy element, jaki zostanie odczytany z bufora. Odczyt spowoduje też przesunięcie indeksu na kolejny element. Ogon natomiast wskazuje ostatni element jaki został zapisany. Kolejny zapis spowoduje przesunięcie indeksu na następny element. Dzięki zastosowaniu tych wskaźników pierwszy element do odczytu nie musi się koniecznie znajdować na początku bufora.

Przy implementacji buforów cyklicznych ważne są dwa zagadnienia – obsługa dojścia do końca tablicy, oraz przepełnienie, czyli sytuacja kiedy zapisanie kolejnego elementu nadpisze pierwszy do odczytu. W takim wypadku mamy do wyboru nie przyjmować kolejnych danych lub nadpisać najstarsze.

Wymagane biblioteki i narzędzia

Kod będzie powstawał w czystym C, a jako frameworka testowego użyty zostanie Unity. Do budowania użyty będzie Cmake dzięki czemu projekt powinien budować się na każdym systemie operacyjnym. Przykład będzie kompilowany i uruchamiany na PC. Nie potrzebujemy więc żadnej płytki embedded. Nie będę na razie wchodził w konfigurowanie builda na PC i HW, ani inne szczegóły. Skupię się na podstawach TDD.

Opiszę zmiany w pliku testowym i produkcyjnych. Aby projekt działał należy jeszcze dostarczyć funkcję main uruchamiającą testy i każdy z nich dodać do runnera grupy testowej.

Implementacja przy użyciu TDD

Zaczynamy od fazy Red, czyli utworzenia pierwszego testu. Nie mamy jeszcze nic, a z opisu powyżej wynika cały zestaw wymagań. Na początku warto więc wypisać je w komentarzach.

Zawartość pliku cyclic_buffer_test.c:

Wybieramy pierwszy z nich i piszemy test:

W pierwszej linijce używamy makra TEST z Unity, a jako argumenty dajemy nazwę grupy testowej (cyclic_buffer) i testu (BufferEmptyAfterInit). Następnie definiujemy i inicjalizujemy bufor, żeby w końcu sprawdzić, czy jest pusty za pomocą asercji.

Co to są grupy testowe, asercje, jak nazywać testy, co powinien zawierać pojedynczy test to wszystko zagadnienia trochę bardziej zaawansowane i nie będę się na nich tutaj skupiał. Zainteresowanych odsyłam na mojego bloga, gdzie można znaleźć dużo materiałów o unit testach i TDD.

Kompilujemy nasz projekt i otrzymujemy błędy:

Nic dziwnego – mamy sam plik testu, a struktury cbuf jeszcze nawet nigdzie nie zdefiniowaliśmy. Błąd kompilacji również jest uznawany za błąd testu. Skutecznie zakończyliśmy więc fazę Red i możemy przejść do fazy Green, czyli implementacji kodu produkcyjnego. Zacznijmy od zdefiniowania typu cbuf.

Zawartość pliku cyclic_buffer.h:

Tym razem po kompilacji czeka nas błąd linkera – brakuje funkcji cbuf_init i cbuf_is_empty:

Definiujemy więc brakujące funkcje. Idziemy tu po linii najmniejszego oporu – w fazie Green naszym celem jest przejście testów najprościej jak tylko będziemy mogli.

Zawartość pliku cycluc_buffer.h:

Zawartość pliku cyclic_buffer.c:

Udało się – testy przechodzą:

Jednak było w tym trochę przypadku – funkcja cbuf_is_empty zwraca losową wartość. Poza tym kod kompiluje się z ostrzeżeniami o nieużywanych argumentach i braku zwracanych wartości.

Mając przechodzące testy możemy więc zająć się usuwaniem ostrzeżeń. Zawartość pliku cyclic_buffer.c:

Zwracamy na sztywno wartość true, bo to najprostsza implementacja przechodząca testy. Na razie nas nie obchodzi, że jest niepoprawna. Jak napiszemy test udowadniający błąd – wtedy ją poprawimy. Takie podejście skłania nas do wybierania najprostszych rozwiązań. Czasem może się okazać, że taka uproszczona implementacja będzie wystarczająca.

Implementacja przy użyciu TDD  - kolejne cykle

Ostrzeżenia mamy poprawione, nie ma nic więcej do refactorowania, możemy rozpocząć drugi cykl. Bierzemy więc kolejny test z listy. Po dodaniu elementu do bufora, odczytujemy ten sam element.

Zawartość pliku cyclic_buffer_test.c:

Mamy tu nowe funkcje, więc pierwsze co musimy poprawić to błąd linkera. Przy okazji komuś może wydawać się bez sensu stosowanie nieistniejących funkcji w teście, a dopisywanie ich dopiero potem. Jednak w ten sposób od razu używamy naszych funkcji i możemy lepiej wiedzieć jakiego interfejsu potrzebujemy, żeby było prosto i intuicyjnie. Tutaj odwrócenie kolejności również służy uproszczeniom.

W każdym razie przy błędzie linkera musimy dodać brakujące funkcje. Przyspieszę trochę opis i od razu zamieszczę też ich implementację.

Zawartość pliku cyclic_buffer.h:

Zawartość pliku cyclic_buffer.c:

Test wcale od nas nie wymaga jeszcze tablicy elementów ani wskaźników head i tail. Dlatego idziemy na skróty i na razie trzymamy tylko jeden element. Na potrzeby aktualnych testów to wystarczy.

Kolejny test sprawdza poprawność zapisu i odczytu dwóch elementów oraz obnaża naiwność naszej dotychczasowej implementacji.

Zawartość pliku cyclic_buffer_test.c:

Wynik:

Teraz już implementacja musi być pełniejsza. Zawartość pliku cyclic_buffer.h:

Zawartość pliku cyclic_buffer.c:

Mamy już bufor działający w prostym przypadku, pora na obsługę wyjścia poza rozmiar tablicy.

Zawartość pliku cyclic_buffer_test.c:

Tym razem uruchomienie testu skutkuje błędem runtime – przekraczamy rozmiar wewnętrznej tablicy i nadpisujemy cudze dane  (przy okazji: takie rzeczy dużo łatwiej debuguje się na PC). Mikrokontrolery nie mają ochrony pamięci i zwykle po prostu nadpiszemy jakieś dane i możemy długo nie wiedzieć, że program "poszedł w krzaki".

Pora na poprawienie implementacji. Zawartość pliku cyclic_buffer.c:

Teraz testy znowu przechodzą:

Pozostało jeszcze trochę testów z listy – ich wykonanie pozostawiam jako ćwiczenie dla czytelników. Przypominam również, że wszystkie potrzebne pliki dostępne są w repozytorium.

Zalety Test Driven Development

Spójrzmy na wyniki naszej pracy. Implementacja bufora cyklicznego jest prosta i dosyć dobrze znana. Bez TDD prawdopodobnie napisalibyśmy taki kod szybciej. Jednak czas potrzebny na stworzenie programu to nie tylko mechaniczne pisanie. Musielibyśmy przecież wykonać jakieś testy, musielibyśmy to robić ręcznie. Poza tym jeśli wrócimy do tego kodu po jakimś czasie to nie będziemy już pamiętać całego kontekstu i testowanie od nowa będzie trwało jeszcze dłużej. W przypadku TDD mamy testy automatyczne, które zawsze wykonują się tak samo szybko i sprawdzają te same asercje.

Poza tym do sprawdzenia poprawności naszego kodu nie musieliśmy ani razu uruchomić debugera. Zupełnie wystarczy nam tekst zwracany przez konsolę.

Rozbudowany przykład TDD

Przedstawiony przykład jest dosyć prosty. Specjalnie na początek nie wybierałem niczego co zależy bezpośrednio od rejestrów sprzętowych. Takie moduły oddzielone warstwą abstrakcji od sprzętu mogą najszybciej skorzystać na TDD. Przy okazji są dobrym przykładem na początek. Polecam również inny przykład TDD na moim GitHubie, gdzie implementuję protokół komunikacyjny.

Podsumowanie

Musimy pamiętać, że TDD nie rozwiąże wszystkich naszych problemów. Istnieją typy błędów, które muszą być rozwiązywane na docelowym systemie np. współbieżność, wydajność, integracja między modułami. Warto więc wspomagać się również innymi dobrymi praktykami. Część z nich opisałem jakiś czas temu w poniższym artykule:

3 różnice w programowaniu: hobbystycznie vs. komercyjnie
3 różnice w programowaniu: hobbystycznie vs. komercyjnie

W pewnym momencie każdy programista musi przestawić się z hobbystycznego… Czytaj dalej »

Po więcej informacji na temat Test Driven Development odsyłam na mojego bloga, gdzie jest cały dział poświęcony tej tematyce. Jeżeli interesujesz się TDD i chcesz poznać odpowiedzi na jakieś konkretne zagadnienia – wystarczy, że zostawisz mi je używając tego formularza. Planuję stworzyć jeszcze więcej materiałów o TDD, nie tylko w formie artykułów, dlatego wszystkie propozycje są dla mnie ważne.

Czy artykuł był pomocny? Oceń go:

Średnia ocena / 5. Głosów łącznie:

O autorze

Autorem wpisu jest Maciek Gajdzica zawodowo zajmujący się programowaniem systemów ebedded, w tym systemów safety-critical. Maciek prowadzi swojego bloga ucgosu.pl, na którym publikuje teksty o programowaniu systemów wbudowanych i robotyce. Jego artykuły kierowanie są głównie do bardziej zaawansowanych czytelników, którzy mają już opanowane podstawy (np. z kursów Forbota). Jeśli taka tematyka jest dla Was ciekawa to zapiszcie się na newsletter ucgosu.pl, a w ramach bonusu otrzymacie wtedy poradnik „Jak zwiększyć jakość kodu w projektach embedded? – darmowe narzędzia”.

Autor tekstu: Maciek (GAndaLF) Gajdzica
Redakcja: Damian Szymański

embedded, programowanie, tdd, testy

Trwa ładowanie komentarzy...