Testy jednostkowe – czy naprawdę ich potrzebujemy?

Unit testy - czy naprawdę ich potrzebujemy?

Testy jednostkowe często bywają nieczytelne. Bardzo trudno je napisać przy istniejącym kodzie odziedziczonym. Wymagają inwestycji czasu i przy większych zmianach część z nich trzeba przepisywać. Po co to komu…?

…a przynajmniej tak myślałem, gdy zaczynałem pracę 10 lat temu w moim pierwszym projekcie. Dzisiaj uważam trochę inaczej i mam zamiar Ci pokazać, czemu testy przynoszą wartość – a kiedy utrudniają życie.

W projekcie, o którym wcześniej pisałem, część testów nie przechodziła, część była zakomentowana, a jeszcze inne co jakiś czas z niezrozumiałych powodów się wywalały. Gdy pytałem “czemu?”, to odpowiedź brzmiała: “odpal je jeszcze raz i nie pytaj”.

Przez takie doświadczenia, w ciągu pierwszego pół roku mojej kariery napisałem niewiele testów. Nie czułem tego konceptu, czułem się zmuszony, by je dorabiać na siłę – bo mi kazano.

Potrzebowałem bardzo ciężkiej przeprawy w pewnym projekcie, by zrozumieć, że umiejętności pisania dobrych testów automatycznych oraz umiejętność podjęcia decyzji, kiedy ich nie warto pisać, są dość subtelnymi narzędziami, które mogą sprawić, że praca programisty przestanie być nieuporządkowanym chaosem. Jednocześnie, jak się zrobi to źle, to mamy do czynienia ze wszystkimi tymi wadami, o których pisałem wcześniej.

Projekt Price Planning

Pierwszym projektem, który dostałem praktycznie na wyłączność, był projekt dla niemieckiego klienta. Klient chciał, by ten projekt został napisany w języku VBA, a formularze osadzone w dokumentach Excelowych na podstawie danych z bazy same odtwarzały swój kształt i wygląd po włączeniu.

Jako, że byłem sam w tym projekcie, to pełniłem też rolę QA. Byłem odpowiedzialny za utrzymywanie spójności, jakości oraz testowalności aplikacji. Pomijając już fakt, że aplikacja powinna robić to, czego klient od niej chciał.

Po przekroczeniu pewnego poziomu złożoności aplikacji zauważyłem, że wprowadzanie zmian stało się bardzo uciążliwe. Gdy testowałem, okazało się, że przypadkowo uszkodziłem inną część aplikacji. Wiele razy musiałem się cofać i naprawiać coś, co myślałem, że działa.

Dodatkowo testy regresji, czyli sprawdzenia, że aplikacja nadal łącznie robi to, co powinna, zajmowały bardzo dużo czasu. Około 20% czasu w ramach jednej iteracji. A wszystko wskazywało na to, że będę marnował na to coraz więcej czasu.

Do głowy przyszło mi, by spróbować to zautomatyzować. Jako, że można w łatwy sposób wyklikać makra w Excelu, pokazałem, jakie czynności mają być wykonane, a następnie napisałem kilka linijek sprawdzających, czy efekt jest taki, jak spodziewany.

Tak samo zacząłem robić dla mniejszych modułów i procedur. Pisałem kod, który sprawdzał, czy moduł lub procedura robią dokładnie to, co powinny.

Doszedłem do wprawy w tym, co robiłem i uzyskałem pewność, że nadal wszystko działa jak powinno. Zbindowałem nawet procedurę, która uruchamia mi inne procedury sprawdzające, pod odpowiednią kombinację klawiszy.

Takim oto sposobem odkryłem coś, co już znałem z poprzedniego projektu, ale czego sensu nie rozumiałem.

Zauważ – napisane przeze mnie procedury to nic innego, jak testy jednostkowe, czy testy integracyjne. Procedury, które wyklikiwałem to były testy GUI.

Nie odkryłem w tym momencie niczego nowego. Ale nagle zrozumiałem, dlaczego warto pisać testy i dlaczego mi – jako programiście – są potrzebne. Nawet dodam, iż poczułem, że są mi niezbędne do pracy.

Poświęciłem trochę czasu na napisanie własnych modułów, które umożliwiały mi testowanie. Generowałem sobie wyniki w osobnym arkuszu.

Dlaczego moje testy były dobre?

Moim celem było zapewnienie mnie, że nie spierniczę niczego, gdy wprowadzę zmiany oraz nowe rzeczy i nowe funkcje, które dodam, mają robić dokładnie to, czego od nich oczekuję. Testy, które robiłem, po prostu automatyzowały dla mnie to, co musiałbym sprawdzić ręcznie.

Nie pisałem testów dla pisania testów, lub “bo mi kazali”. Pisałem testy, bo dzięki temu miałem dużo mniej pracy – zwłaszcza tam, gdzie najłatwiej było popełnić błąd i gdzie najbardziej mnie to frustrowało.

Od razu się przyznam, że były one czytelne, ale tylko dla mnie 😉 Wszystkie osoby, którym je pokazywałem, zastanawiały się, co tak właściwie się dzieje? Ale to nie miało znaczenia: posiadanie nawet takich testów było lepsze, niż ich brak (mniej błędów, mniej czasu, większa pewność siebie).

Wiedziałem już wcześniej, jaka jest definicja książkowa dobrych testów, ale dopiero w tym projekcie zrozumiałem na własnej skórze, jak istotne jest pisanie dobrych testów.

Co zatem oznacza “pisanie dobrych testów”?

Dobre testy powinny być deterministyczne. W szczególności testy jednostkowe.

W pierwszym projekcie testy jednostkowe wywalały się w czwartek albo losowały wartość od 1-5 i tyle czasu czekały.

Ja potrzebowałem, by te testy za każdym razem dawały ten sam wynik, jeśli kod działa poprawnie. Bez tego nie miałem pewności, że mogę moim testom ufać i musiałem wszystko weryfikować ręcznie tak, jakbym tych testów nie miał.

Interesowało mnie, by na czerwono wypisać to, co nie działa. Dzięki temu zawsze wiedziałem precyzyjnie, gdzie mam szukać przyczyny problemu – co mam poprawić? To oszczędzało mi czas na pracę z debuggerem i budowanie tych pierwszych hipotez.

Druga istotną cechą była prędkość testów. Początkowo testowałem z poziomu GUI i używałem bazy danych. Przez to moje testy trwały za długo.

Doszedłem do wniosku, że warto testować rzeczy w izolacji, bez zewnętrznych elementów takich, jak baza. Po prostu testowanie z żywą bazą danych zajmowało za dużo czasu.

Trzecia rzecz, którą wtedy zrozumiałem: nie warto pisać testu dla każdego przypadku. Niektóre przypadki po prostu nie wystąpią.

Wyobraźcie sobie, że macie moduł, który liczy sumę dwóch liczb. C = A + B. Możemy go przetestować dla każdej pary liczb. Ale po co? Czym różni się test “2+2” od testu “2+3”?

Tak naprawdę ważne jest to, by sprawdzić, dla jakich wartości możliwa jest operacja, a dla jakich spodziewamy się problemów. Te problemy, bugi w kodzie, nieoczekiwane zachowania, to jest coś, przed czym warto się ustrzec pisząc kod testowy. Sprawdzić główne przypadki, przy których kod ma zachowywać się inaczej (przedziały równoważności).

Potem

Gdy zacząłem pracować w kolejnym projekcie i dostałem zadanie napisania funkcji systemu, która jest podobna do pewnego istniejącego procesu, ale jednak się różni dość znacząco, to pierwszym krokiem było … napisanie testów dla tamtego procesu.

Dlaczego?

  • Aby zrozumieć jak działa….
  • By się zabezpieczyć przed tym, bym czegoś nie zepsuł…
  • Aby mieć dowód, że robię wszystko tak, jak powinienem….
  • By się nie bać tego dać do testów QA….

W .Net pisanie testów to była przyjemność. Nie musiałem wymyślać koła na nowo – wystarczyło z Nugeta ściągnąć kilka bibliotek i już wszystko działało. Dobra integracja z Visual Studio i mogłem praktycznie tworzyć nowy kod bez strachu.

Dzięki temu, że podejście Sometimes Test First weszło mi w krew, było mi dużo łatwiej dbać o jakość kodu, także tego testowego.

Fakt, że przy każdym budowaniu aplikacji odpalało się wszystkie testy, dawał nam gwarancję, że nasza aplikacja działa tak, jak powinna (a przynajmniej tak, jak uważaliśmy, że powinna). Nigdy jednak nie mieliśmy czasu, by pokryć testami 100% przypadków biznesowych, bo były inne priorytety i pewnie w większości przypadków nie warto było marnować na to czasu. Zawsze warto znaleźć punkt good enough i iść do przodu.

Chrzest w ogniu

Ostatnią sytuacją, w której testy, a w szczególności testy jednostkowe pokazały mi 100% przydatności, był wewnątrzfirmowy konkurs programistyczny Deadline 24 lite.

Moim zadaniem było napisanie bota, który poprzez sieć i wydawanie odpowiednich komend grze rywalizował z innymi botami, pisanymi przez innych graczy.

Komunikacja działała (napisaliśmy testy jednostkowe, które nam to udowadniają).

Należało napisać algorytm, który decydował, które części robota warto kupić na bazie poprzednich zakupów.

Trzeba było napisać kawałek kodu, który uruchamiany był raz na kilka jednosekundowych rund.

Wiedziałem, że będzie to dość problematyczne, więc postanowiłem napisać testy zarówno dla algorytmu, którego odpowiedzialnością było budowanie najlepszego zestawu połączeń części robota, jak i dla algorytmu kupowania odpowiednich części robota.

Brak tych testów wymagałby ode mnie analizy logów i debugowania czegoś, co zdarza się raz na 250 rund, przez co nie mógłbym uczestniczyć w rozgrywce – bot czekałby, zamiast grać. Ważne, że to działo się w czasie rzeczywistym – gdy bot nie grał, nie dostawałem punktów.

Tak więc w czasie, gdy ja pisałem nowy kod, stara wersja (czasem też średnio skuteczna) bota grała, a ja skupiałem się, by najpierw napisać, czego się spodziewam po wyniku testu, a dopiero potem kod algorytmu.

Podsumowanie

Testy mają bardzo szeroki wachlarz zastosowania. Można tworzyć je na poziomie jednostkowym (testowania funkcji, metod, modułów), integracyjnym (złożenie kilku elementów).

Testy wymagają trochę innego spojrzenia. Należy wcześniej się zastanowić, jak coś działa, jak zareaguje na takie wartości, co będzie na wyjściu, a przede wszystkim, co projektowany komponent powinien zrobić.

Ich główną zaletą jest testowanie tego, czy nasza aplikacja działa zgodnie z tym, co napisaliśmy w testach. Dobrze napisane testy dają nam większą szansę na wykrycie potencjalnych błędów w trakcie modyfikowania kodu, lub dodanie nowego.

Testy dają nam możliwość automatyzacji pewnych czynności, które pewnie musielibyśmy wykonać manualnie. Co też zajmuje czas.

Warto rozszerzyć swój narzędziownik o umiejętność pisania testów, oraz zrozumieć, kiedy warto pisać testy, a kiedy nie.

Mi osobiście testy pomogły w różnych sytuacjach i różnych projektach. Pisanie dobrych testów wymaga czasu i doświadczenia – czyli pisania, pisania i jeszcze raz pisania.

O różnych rodzajach testów poczytasz tutaj.

W dzień Senior Software Developer w firmie Future Processing, w nocy śpi. Ponad 8 lat doświadczenia w zakresie wytwarzania oprogramowania w różnych technologiach oraz domenach, również w takich, w których nikt nie chciał pracować. Zafascynowany rozwojem technologii związanej z przetwarzaniem danych a w szczególności tworzeniem rozwiązań z rodziny Big Data. Prelegent oraz organizator licznych wydarzeń, których głównym celem jest dzielenie się wiedzą oraz krzewienie potrzeby stosowania dobrych praktyk, w celu maksymalizacji jakości wytwarzanego produktu. Współorganizator Wakacyjnych Praktyk w Future Processing oraz prowadzący przedmiot na Politechnice Śląskiej „Tworzenie Oprogramowania w Zmiennym Środowisku Biznesowym”.
PODZIEL SIĘ