Próbując wprowadzić TDD w projekcie najczęściej spotkamy się z oporem. Argumenty przeciwko tej technice ze strony developerów i osób decyzyjnych, które nie miały z nią do czynienia często się powtarzają. Postanowiłem więc w tym wpisie zebrać te argumenty i je omówić. Krytyka TDD ze strony osób mających doświadczenie w temacie zwykle przybiera inną formę i jest to temat na osobny wpis.

Pisanie testów zajmuje zbyt dużo czasu

Brak czasu to podstawowy argument przeciwko pisaniu testów. Jest bardzo często używany przez managerów oraz przez niektórych devów niechętnych zmianie przyzwyczajeń, albo po prostu nie mających doświadczenia z TDD. Nawet jeśli początkowo uda się przekonać zespół i osoby decyzyjne do pisania testów, często w odpowiedzi na napięte dedlajny podjęta zostaje decyzja o zaprzestaniu testów, żeby przyspieszyć development.

Prawda jednak jest taka, że pisanie zgodnie z TDD nie zajmuje wcale więcej czasu niż pisanie metodą tradycyjną. Problem polega na tym, że czas, który rzeczywiście poświęcamy na implementację jest wtedy bardziej widoczny. Więcej czynności wykonujemy w tym podstawowym czasie na wykonanie zadania, a mniej zostaje ukrytych w czasie na testowanie po napisaniu i naprawianie błędów. Nawet jeżeli początkowo ten czas jest niedoszacowany, to szybko dowiadujemy się ile go w rzeczywistości potrzeba. Wtedy często okazuje się, że ta rzeczywistość nie pasuje do tabelki w Excelu. Ogólnie jest to dobre zjawisko, ponieważ w miarę szybko PM otrzymuje feedback i można zrewidować nierealistyczny plan. Niestety często zamiast tego osoby decyzyjne wolą zrezygnować z testów, aby uzyskać iluzję wyrabiania się w terminie. Niestety później problem wraca ze zwiększoną siłą.

Aby udowodnić, że pisanie zgodnie z TDD nie zajmuje więcej czasu, zastanówmy się jakie czynności wykonujemy podczas implementacji metodą tradycyjną. Poza samym pisaniem kodu, są to również:

  • Ręczne sprawdzanie działania.
  • Dopisywanie kodu testowego wywołującego określoną funkcjonalność.
  • Przechodzenie kodu krokowo przy użyciu debuggera.

Dużą część tej pracy tracimy od razu po jej wykonaniu, albo musimy ją powtarzać wiele razy.  Co więcej są to czynności, których zwykle nie uwzględniamy, albo niedoszacowujemy w estymatach czasu. Dzięki TDD możemy je zautomatyzować i zostaje po tej pracy coś przyszłość, a czas na nie poświęcony jest łatwiejszy do oszacowania. Więcej o tym pisałem w poprzednich częściach cyklu(link1, link2).

Testy można dopisać później

Kiedy już uda nam się udowodnić, że testy automatyczne są potrzebne i wcale nie wydłużają developmentu, a często nawet przyczyniają się do jego przyspieszenia, musimy pokonać kolejną przeszkodę – „Dobrze to dorobimy testy do kodu tylko, że później”. Musimy tutaj zdać sobie sprawę, że mówiąc później ta osoba ma na myśli bliżej nieokreśloną przyszłość, najlepiej żeby nigdy do niej nie doszło. Albo jeszcze lepiej, żeby te testy jakoś magicznie same się pojawiły.

Jeżeli jednak już przyjdzie nam dopisywać po czasie testy do istniejącego kodu (szczególnie napisanego przez kogoś innego), szybko okaże się, że jest to udręka. Pierwszym problemem jest to, że aby napisać testy trzeba go dokładnie przeanalizować i przypomnieć sobie kontekst. Jeżeli minęło już trochę czasu od napisania, na pewno nie będziemy pamiętać, dlaczego w konkretnych sytuacjach zrobiliśmy tak a nie inaczej i dlaczego ta linijka jest potrzebna. Dlatego testy napisane po czasie zajmują więcej czasu i nie są tak dokładne.

Kolejną przeszkodą jest fakt, że kod być może nie został napisany z myślą o testowaniu. Przez to ciężko nam przetestować niektóre założenia, bo nie możemy np. zweryfikować wyniku jakiejś operacji, albo wymaga to dużej ekwilibrystyki. Testowane funkcje mogą być długie i zawierać skomplikowaną logikę, przez co testy muszą zawierać dużo kodu konfiguracyjnego. Jeżeli dodatkowo dopisując testy nie możemy modyfikować kodu produkcyjnego, niektóre rzeczy są wręcz niemożliwe do przetestowania.

Oczywiście decydując się na dopisywanie testów później dobrowolnie rezygnujemy z pozytywnego wpływu TDD na design i czystość kodu. Dodawanie testów do istniejącego kodu jest trudną i niewdzięczną robotą. Brakuje tego elementu satysfakcji, kiedy widzimy zielony pasek. Poza tym testy dopisane później  często są tworzone sugerując się kodem, więc często asserty są dostosowane do istniejącej implementacji. Takie testy są podatne na błędne założenia, co skutkuje przepuszczaniem błędów na produkcję. Podsumowując – testy napisane później wyłapują mniej błędów, są mniej dokładne i powstają dłużej.

Kod testów również należy utrzymywać

Owszem, jeśli decydujemy się na TDD powstaje dużo dodatkowego kodu, który należy utrzymywać.  Często jest go nawet więcej niż kodu produkcyjnego. Aby testy stanowiły jakąś wartość, muszą ewoluować razem z projektem. Należy dbać o to, żeby nowe funkcjonalności nie zepsuły istniejących testów, żeby wszystkie testy się budowały i przechodziły. Poza tym sam kod testów musi być czytelny i łatwy do zmiany. Jest to całkiem sporo dodatkowej pracy.

Ale jest to dodatkowa praca, na którą godzimy się świadomie. Zalety płynące z posiadania unit testów takie jak szybki feedback po wprowadzonej zmianie, czy jasne sprecyzowanie założeń dotyczących napisanego softu przewyższają koszty związane z dodatkową pracą.

Nie znajdziemy w ten sposób wszystkich błędów

Tak, unit testy i TDD nie są gwarancją wyłapania wszystkich błędów. Są tylko sposobem na ograniczenie ich ilości. Poza tym istnieją klasy błędów, których w ten sposób nie znajdziemy. Unit testy sprawdzają jedynie, czy napisany kod działa zgodnie ze zrozumieniem wymagań przez developera. Jeżeli poczynił on błędne założenia pisząc test, kod będzie te błędy odzwierciedlał.

Poza unit testami potrzebne są również inne poziomy testów takie jak performance, integracyjne, czy systemowe. To na tych testach powinny wyjść inne rodzaje błędów, których unit testy nie wyłapią. Dzięki unit testom na tych wyższych poziomach można się skupić na faktycznym sprawdzani performance, czy integracji. A nie tracić czas, bo gdzieś w kodzie jest > zamiast >=.

Mamy istniejący kod bez testów

Teoria TDD wydaje się dosyć prosta, ale zakłada, że projekt jest prowadzony w ten sposób od początku. Jednak często mamy do czynienia z systemami rozwijanymi od dłuższego czasu, gdzie powstało już dosyć dużo kodu. Jak byśmy zaczynali od początku to może byśmy się zastanowili, ale teraz nic nie możemy zrobić.

Faktycznie, wprowadzanie TDD do istniejącego projektu jest poważnym problemem. Legacy Code był zmorą niejednego programisty i to nie tylko w kontekście TDD. Powstały na ten temat różne publikacje. Zwykle pod tym pojęciem rozumiemy kod, który posiada duży dług technologiczny i nie jest prowadzony zgodnie z dobrymi praktykami.

Skoro musimy się mierzyć z takimi problemami, wprowadzenie TDD do projektu z Legacy Code nie jest proste. Należy trzymać się tutaj kilku zasad:

  • Do nowych funkcjonalności i poprawianych bugów na bieżąco dodajemy testy. Chodzi o to, aby dodatkowo nie pogarszać sytuacji.
  • Stopniowo pokrywamy testami istniejący kod zaczynając od kluczowych fragmentów. Dysponując testami sprawdzającymi funkcjonalność, wykonujemy refactor.
  • Nie próbujemy otestować całego kodu za jednym zamachem, zmiany powinny następować stopniowo.

Jest to żmudny proces, jednak alternatywa w postaci wprowadzania zmian w kodzie bez informacji, czy funkcjonalność została zachowana, jest jeszcze gorsza.

Tego kodu nie da się przetestować

Kod często wchodzi w interakcję ze światem zewnętrznym i na pierwszy rzut oka może się wydawać, że napisanie unit testów, które to sprawdzają nie jest możliwe. Do tego typu problematycznych zależności należą między innymi:

  • Hardware
  • Bazy danych
  • Sieci

Częstym podejściem jest – i tak się tych zależności nie da przetestować, więc nie będziemy robić testów w ogóle. Poprawnym rozwiązaniem w takim wypadku jest ukrycie tych zależności za warstwą abstrakcji i wykorzystanie do testowania mocków. Dzięki temu design aplikacji jest lepszy. Moduły są od siebie bardziej odseparowane i możliwe jest łatwiejsze portowanie.

Napisanie unit testów do kodu operującego bezpośrednio na rejestrach hardware’owych jest możliwe. Wartość takich testów jest jednak niewielka, a ich napisanie jest dosyć skomplikowane. Konfigurację tych rejestrów i tak trzeba sprawdzić na docelowym hardware. Dlatego faktycznie pisanie tego rodzaju testów się nie opłaca. Należy w takim wypadku wydzielić nietestowany kod do odrębnego modułu, a zgodnie z TDD pisać wyższe warstwy.

Podsumowanie

Podstawową wymówką, aby nie stosować TDD jest chęć oszczędności czasu. Należy wtedy uświadomić taką osobę, że czasu potrzebnego na implementację i testowanie danej funkcjonalności nie można traktować rozłącznie. Ten czas zawsze składa się z dwóch składowych nawet, kiedy jedna z nich jest ukryta i nie widnieje jawnie w harmonogramach. Pozostałe argumenty faktycznie poruszają ważne problemy, na które nie ma prostych odpowiedzi. TDD może w tych aspektach przynieść korzyści, ale trzeba włożyć w to trochę wysiłku.