Kiedy uczyłem się programować, pisałem metodą code and fix. Czyli najpierw pisałem jakiś fragment kodu – mogła to być jedna funkcja, moduł albo nawet cały program. Następnie uruchamiałem go i ręcznie sprawdzałem czy działa, przechodziłem kod debuggerem sprawdzając wartości zmiennych i przepływ sterowania. Następnie poprawiałem znalezione błędy, dodawałem funkcjonalności i znowu sprawdzałem. Na pewno każdy programista zaczynał w ten sposób.

W miarę jak moje umiejętności rosły i pisałem trudniejsze programy zauważałem coraz więcej problemów związanych z tą „metodologią”. Miałem jednak przeświadczenie, że robienie skomplikowanych aplikacji po prostu musi być skomplikowane. Tym bardziej, że na studiach (co prawda z Automatyki, a nie Informatyki) nikt się nawet nie zająknął o automatyzacji testów, o Test Driven Development nie wspominając.  W kolejnych akapitach wytłumaczę, o jakie problemy mi chodziło.

Pierwsze uruchomienie następuje późno

Zanim jesteśmy w stanie zobaczyć efekty działania napisanego kodu, musimy napisać go całkiem sporo. Żeby przetestować działanie danej funkcji, powinniśmy mieć zaimplementowane wszystkie funkcje, które będzie wywoływać. Musimy także mieć kod inicjalizacyjny, który jest wykonywany przed wejściem do interesującej nas funkcji. Często kiedy przychodziło do pierwszego uruchomienia program w ogóle się nie kompilował, albo działał zupełnie niezgodnie z założeniami i aby doprowadzić go do użytku, trzeba go było zupełnie przeorać.

Pisząc tyle kodu przed uruchomieniem musimy więc podjąć pewne decyzje architektoniczne w ciemno, a następnie często widząc, że się nie sprawdzają, trwamy przy nich zbyt długo, bo przecież nie chcemy grzebać przy czymś co działa. Jeszcze przypadkiem coś zepsujemy.

U mnie dodatkowo sytuację komplikował fakt, że piszę na systemy Embedded, które mają ograniczone możliwości pokazywania efektów działania na wyjściu. Do sprawdzania działania kodu często więc używałem debuggera. W ten sposób przeglądałem tylko świeżo dopisane fragmenty, bo przecież nie chciało mi się w kółko oglądać jak działają te same linie kodu.

Działa, więc lepiej tego nie ruszać

W miarę postępów projektu, ilość kodu się zwiększa. Do wcześniej napisanego kodu trzeba wracać, aby dodawać nowe funkcjonalności i poprawiać błędy. Po wprowadzeniu poprawek przestają działać elementy, które wcześniej działały bez zarzutu. Często wracają również błędy, które już kiedyś poprawialiśmy. Albo okazuje się, że zrobiony fix nie usuwa błędu w pełni.

Z czasem zapominamy do czego służyły pewne fragmenty, albo nie wiemy dlaczego kto inny tam je umieścił. Nawet jeśli ten kod już nie jest potrzebny, boimy się go usunąć, żeby przypadkiem niczego nie zepsuć. Albo jeszcze lepiej – zostawiamy go, bo może kiedyś się przyda.

Po pewnym czasie powstają całe bloki, które omijamy szerokim łukiem. One działają, a każda zmiana może spowodować, że przestaną. A jeśli nie daj Boże piszemy w kilkuosobowym zespole i mamy zmienić coś w nie swoim kodzie, próbujemy za wszelką cenę tego uniknąć. Jeśli więc któryś z developerów opuści projekt, mamy spory problem.

Testowanie nie jest dokładne

Żeby sprawdzić, czy zaimplementowana funkcjonalność działa poprawnie, często trzeba wykonać skomplikowany ciąg czynności. Jeśli nie działa, to nie wiadomo, czy dlatego że jest błąd w kodzie, czy np. coś nie tak wyklikaliśmy. A niektórych rzeczy w ogóle nie da się sprawdzić ręcznie, bo na przykład dzieją się zbyt szybko.

Po wprowadzeniu nowego kodu trzeba sprawdzić, czy stara funkcjonalność jest zachowana, co oznacza żmudne powtarzanie tych samych czynności. Im częściej takie sprawdzenie musieliśmy wykonywać, tym większa szansa, że robimy to mechanicznie, bez należytej uwagi i przeoczymy błąd. Przez to problem, który kiedyś znaleźliśmy, a teraz się odnowił, może nie zostać znaleziony, jeśli robimy to samo sprawdzenie po raz pięćdziesiąty.

Widząc, że tak wygląda testowanie, tracimy zaufanie do poprawności napisanego kodu. Kiedy pracowałem w ten sposób, była to dla mnie bardzo niekomfortowa sytuacja, ponieważ podchodziłem wtedy do tworzonego przez siebie kodu bardzo osobiście. Jeżeli ktoś znalazł błąd w tym, co napisałem, traktowałem to jako moja wina.  Szczególnie stresujące były dla mnie prezentacje prototypów u klienta, w których zdarzało mi się brać udział, oraz błędy, które wtedy wychodziły.

Problemy z estymacją czasu

Pracując nad projektem programistycznym na pewno nie unikniemy pytań o czas jaki będziemy potrzebować na wprowadzenie danej funkcjonalności. Częstą odpowiedzią osób pracujących metodą code and fix jest „Na samą implementację 3 dni, ale potem jeszcze potrzebuję czasu na testy”.  Tego czasu na testy nie da się z góry przewidzieć. Jeżeli wszystko zadziała od razu – co w praktyce nigdy się nie zdarza – wystarczy chwila, ale równie dobrze może to trwać kolejne 3 dni. Bardzo często ten czas na testy jest niedoszacowany. A przez to, że testy są bardzo pobieżne, przepuszczają sporo błędów. Możemy więc spodziewać się tekstu w stylu „Przecież miałeś czas na testy, więc dlaczego są błędy?”.

Skoro jesteśmy przy estymacji czasu, to warto zwrócić uwagę na ciekawą zależność. W projektach prowadzonych w opisywany przeze mnie sposób bardzo często jest tak, że na początku dodawanie nowych funkcjonalności idzie bardzo sprawnie. Bazując na tych początkowych postępach tworzone są planowane harmonogramy. Jednak w miarę rozwoju projektu okazuje się, że postępy następują coraz wolniej. Przyczyną takiej sytuacji jest rosnące skomplikowanie projektu, zależności pomiędzy modułami i coraz więcej wychodzących błędów. Skutkiem tego są przekroczone budżety, niezrealizowane dedlajny, niedziałający produkt, rosnący stres i poczucie, że ten projekt nigdy się nie skończy.

Podsumowanie

Przedstawione problemy skłoniły mnie do poszukiwań sposobów na wyjście z tej programistycznej niedoli i właśnie wtedy dowiedziałem się o czymś takim jak Test Driven Development. Oczywiście nie było tak, że tylko to zastosowałem i jak za dotknięciem czarodziejskiej różdżki wszystkie problemy zniknęły. TDD wymagało ode mnie poznania teorii, dyscypliny i zdobycia doświadczenia w stosowaniu tej metodologii. Jednak był to dla mnie wielki krok naprzód, dzięki któremu jakość tworzonego przeze mnie oprogoramowania poszła mocno w górę. I zmienił się mój sposób myślenia na temat programowania i podejście do błędów. Miało to wpływ również na to jak piszę programy, kiedy z jakiś przyczyn nie mogę robić do nich unit testów.

Opisane przeze mnie problemy w pewnym stopniu rozwiązuje ogólnie wprowadzenie testów automatycznych, nie koniecznie muszą to być unit testy. Jednak unit testy i TDD jest tym co developer może zrobić samemu. Testy wyższych poziomów wymagają zwykle rozszerzenia zespołu o testerów, którzy będą zajmować się tylko tym. Kolejnym czynnikiem w pewnym stopniu poprawiającym sytuacje, szczególnie jeśli chodzi o zależności między modułami i obszary, których nikt nie chce dotykać, są zasady pisania czystego kodu takie jak SOLID.

Ten artykuł jest wprowadzeniem do całej serii dotyczącej TDD. Ostatnio prowadziłem szkolenie pt. Test Driven Development dla Systemów Embedded i w związku z tym przygotowałem wiele materiałów zarówno o TDD w ogóle, jak i o jego wykorzystaniu w embedded i w najbliższym czasie będę je tutaj publikować.