Długo zbierałem się do tego artykułu, zrobiłem sobie nawet do niego test, bo lubię pisać o tym, czego sam spróbowałem. Zacznijmy więc od testu. Postanowiłem rozwiązać dokładnie ten sam problem trzy razy. Pierwszy żeby przejść przez zadanie, drugi raz z testami jednostkowymi, a trzeci bez testów. Jak myślisz, co wynikło z tego małego eksperymentu?


Mariusz Walczak. Tech lead w Softfin. Absolwent Warszawskiej Wyższej Szkoły Informatycznej. Pasjonat inżynierii oprogramowania, swoje aplikacje tworzy w PHP i językach opartych na ES6/7. Prywatnie miłośnik futrzanych czworonogów, oraz winiarstwa i nalewkarstwa.


Zadanie testowe

Zadanie było trywialne. Tak poważnie mówiąc odkryłem koło na nowo, a proces wyglądał tak. Nie używając gotowych bibliotek matematycznych miałem za zadanie napisać aplikację, która poda wynik działania z ciągu znaków. W tym ciągu nie będzie nawiasów ani potęg i pierwiastków, czyli zwykłe +-*/.

Przykład

Dane wejściowe :

„1+2*3”

Wynik:

7

Cała sztuka. Nic trudnego prawda?

Na początku suche przejście, zadanie wykonane i fajnie.

Testujemy!

Tutaj już działałem ze stoperem. Na początku w 6 minut napisałem proste testy działania całej klasy, czyli ustaliłem sobie, że moja klasa licząca będzie nazywała się Calculator, a metoda, która będzie zwracała mi wynik, będzie nazywać się calculate (‘wyrażenie matematyczne’).

Przygotowałem z 10 prostych testów, sprawdziłem czy mogę podzielić przez zero i od razu ustaliłem, co powinno mi się zwrócić w tym wypadku. Dodatkowo przetestowałem różne konfiguracje działań, aby sprawdzić czy kolejność ich wykonywania jest prawidłowa, no i sprawdziłem także zastosowanie liczb ujemnych. Napisanie tego typu testów zajęło mi 6 minut.

Następnie poświęciłem 9 minut na przetestowanie każdej z metod — od razu mówię, że nawet nie utworzyłem jeszcze klasy Calculator. Napisałem metody sprawdzające, czy metody: add, substract, divide i multiply robią, co powinny prawidłowo. Sprawdziłem, czy parser ze stringa do tablicy znaków też działa jak należy, czyli dzieli mi ciąg znaków 1+2*3 na elementy i wrzuca do tablicy, aby uzyskać [1,’+’,2,’*’,3], oraz przetestowałem metodę zwracającą kolejność zadań.

Na tym przykładzie widać, co się właśnie wydarzyło. Nie wchodząc w implementację, oraz w żadne czyste kodzenie, utworzyłem sobie zestaw danych do testowania każdej z metod. Ba! Nawet już ustaliłem jak metody będą się nazywać i co będą robić. Czyli de facto rozpisałem całą strukturę klasy i przygotowałem sobie do tego dane, które sprawdzą czy to działa.

W moim IDE ustawiłem sprawdzenie kodu na zapis. I od tego zaczyna się bajka. Całą klasę utworzyłem w 3 minuty. Co zapis to widziałem, ile testów mi się wysypało, a ile przeszło. Nie musiałem nic nigdzie klikać, doskonale miałem już rozrysowane wszystko w głowie, wytarczyło to tylko przelać na kod.

Bez testów!

Następnego dnia, zrobiłem to samo, co wyżej, ale bez testów. Wyszło mi 21 minut, czyli o 3 minuty dłużej. Dlaczego? Zauważyłem, że debugowanie w trakcie pochłaniało najwięcej czasu, choć zadanie było trywialne i już je raz robiłem, także strukturę miałem podobną i to co się dzieje w metodach też. To jednak wrzucanie danych cząstkowych do konsoli i mockowanie danych wejściowych było o wiele bardziej pracochłonne.

Mit – nie mam czasu na testy, testy wydłużają pracę

Powyższe testy wykonałem bez żadnego frameworka, natomiast są do tego gotowe frameworki, które sprawiają, że testy te można pisać jeszcze wydajniej. Z pozoru wydłuża to pracę, szefostwo spojrzy, nie mamy czasu, a oni chcą coś dodatkowego pisać. Wielu developerów, którzy nie próbowali pisać testów, uważają dokładnie tak samo. Patrzą na test jako na dodatkową pracę.

Natomiast trzeba na to spojrzeć zupełnie inaczej. Testy sprawiają, że praca jest szybsza:

  • izolują pracę nad strukturą i przepływem danych, a pracę z algorytmami i czystym kodem,
  • dostarczają nam zestaw danych wejściowych i wyjściowych, przez co nie musimy co chwila tego wpisywać,
  • dobrze napisane testy niwelują prawie do zera potrzebę wyrzucania danych cząstkowych do konsoli i mockowania danych w trakcie developerki,
  • dobrze napisane testy, chronią system przed przypadkowymi błędami.

Te cztery powody tylko skracają czas pracy oraz zabezpieczają nas przed nagłymi pożarami. Dzięki nim niwelujemy ryzyko, że ktoś zmieni coś, co gdzieś w zakamarkach naszego systemu spowoduje błąd. Developerzy nie muszą testować całości miliony razy ręcznie. Robią to za nich testy, automatycznie.

Co więcej testy robią się w ciągu kilku sekund. Często w tym samym czasie nasza aplikacja podczas testu dopiero się załaduje.

Skąd ten mit, w takim razie?

Dostrzegam dwie przyczyny:

  1. Programiści nigdy nie pisali aplikacji opartej na dobrze napisanych testach.
  2. Programiści pisali aplikacje, gdzie jakiś purysta zbyt dużą uwagę przykuwał do testów.

Zacznijmy wyjątkowo od drugiego aspektu. Kiedyś widziałem taki projekt, gdzie w zasadzie na jedną klasę szło ponad 100 testów. Testowane było wszystko, dosłownie wszystko, czasem tak absurdalne rzeczy, że w zasadzie nie było sensu pisać takich testów. I tu jest “pies pogrzebany” — nie było sensu. Sam twórca TDD powiedział, że jak widzi co ludzie zrobili z jego założeniami, to on sam jest przeciwny TDD. W jego założeniu testy miały sprawdzać główne założenia aplikacji oraz wartości skrajne i oczywiste punkty, gdzie mógłby wystąpić błąd.

Puryści zaczęli przewidywać dosłownie wszystko, każdy najdrobniejszy aspekt, każdą pierdółkę. Analiza takich przypadków była nadmiernie rozciągnięta w czasie.

Pierwszy punkt. Programista to pracowite zwierzątko, które nie lubi wychodzić poza swoje gniazdko. Skoro tak jak pisze: działa, ma swój warsztat, z którego wszyscy są zadowoleni, to czemu to robić? Osobiście jestem leniwym stworem. Dlatego co mogę to automatyzuję. Ostatnio nie chciało mi się robić miliona widoków, to napisałem do tego aplikację, która na podstawie pliku konfiguracyjnego, utworzyła mi pliki ze wszystkimi widokami. Dodatkowo moja aplikacja była oparta na testach, bo nie chciało mi się sprawdzać, co chwila jak wygląda zwrotka, tak napisałem w teście jak powinna wyglądać i w przypadku, kiedy dane porównywane nie były równe, to mi konsola pokazywała co otrzymałem. Na tej podstawie poprawiałem moją aplikacje, do momentu aż uzyskałem wszystkie elementy jakie chciałem i klasa scalająca do gotowych widoków robiła to poprawnie.

Całość zadania zajęła mi 6 godzin. Aplikacja kupiła mi 4 dni pracy, a testy około 4 godziny testowania. Było warto? Co więcej testy znów nie były oparte na żadnym frameworku, zwykłe porównanie, jedna metoda napisana na kolanie w 30 sekund.

Jak testować?

Do testów warto się przygotować, przemyśleć sobie kilka rzeczy. Najpierw zastanówmy się nad zadaniem, stwórzmy w swojej głowie klasę i ją wywołajmy w teście, potem przemyślmy jakie będzie miała metody publiczne i rozwiązujemy sobie powoli problem. W moim przypadku był to dialog. Zadałem sobie pytanie: mam ciąg znaków, co muszę mieć, aby móc na tym dobrze operować. Padło na tablicę cyfr i znaków matematycznych. Potem stwierdziłem, że trzeba zobaczyć jakie działanie trzeba wykonać. Więc napisałem test najpierw do metody, która miała zwrócić tablice, a następnie do metody, która by segregowała kolejność działań na podstawie wszystkich dostępnych działań.

Wybierałem to, które powinno być pierwsze, wtedy biorę sobie liczbę z lewej i liczbę z prawej od wybranego znaku i wysyłam do jednej z czterech metod. Przetestowałem od razu wszystkie cztery metody, wtedy już wiedziałem, że będę tą czynność wykonywał, aż otrzymam ten ostateczny element, który jest moim wynikiem.

W ten prosty sposób powstała cała struktura klasy, jej metody, oraz co w której ma się dziać. Po przemyśleniu tego, przystąpiłem do kodowania.

Moje rady odnośnie tego, ile testów i jakich ma być, aby dobrze się pracowało. Przede wszystkim testy muszą sprawdzić scenariusz idealny, czyli wtedy kiedy wszystkie dane są uwzględnione, potem sprawdzamy przypadki skrajne, czyli tam gdzie ma być limit z jakiegoś powodu. Potem sprawdzamy przypadki, gdzie może wystąpić „głupi błąd”, np. sprawdzamy czy metoda zwróci dobry błąd, jeżeli dostanie nie ten obiekt itd. To co jest potrzebne. Po napisaniu tych najbardziej oczywistych testów, przystępujemy do kodowania, podczas którego możemy wywołać jakiś błąd przypadkowo, to uzupełniamy testy, jeżeli ktoś kiedykolwiek spowoduje u nas nieobsługiwany błąd, to wtedy dopiszmy go do testów. Umówmy się, że nie przewidzimy wszystkiego, bo i tak nam się to nie uda. Napiszmy to co najbardziej oczywiste, a resztę sobie uzupełnimy z czasem.

TDD w niecodziennych zadaniach

Zdarzyło mi się kiedyś napisać skrypt, który na zasadzie TDD nie testował żadnej klasy tylko dane w bazie. Mieliśmy problem z krnąbrnymi ludźmi. W regulaminie był zakaz wprowadzania do treści telefonów. Więc nasi klienci zaczęli robić to masowo, my zaczęliśmy to wycinać, to oni zaczęli kombinować. Bitwa rozgorzała. Dziennie dostawaliśmy około 10 mln wpisów, nasze walidatory sprawdzały to na bieżąco przy sprawdzaniu, ale liczba ciekawostek rosła w zastraszającym tempie. Okazało się, że weszliśmy na etap, kiedy w zależności od kontekstu, mógłby to być numer telefonu, lub inna ważna dana.

Wtedy wpadliśmy na pomysł, żeby wykorzystać framework do testów. Co 15 minut pobieraliśmy losową próbkę danych, około 20% z nowo dodanych i mając rozszerzone walidatory, o reguły wyłapujące też dane, które można było publikować. Testowaliśmy czy były w porządku.

Jeżeli dany użytkownik został „odłowiony”, że coś nam tutaj nie pasuje, to testowaliśmy jego wszystkie dane jakie wprowadzał do naszego serwisu. Jeżeli wystąpień było więcej niż 80% dostawał bana. Jeżeli mniej to trafiało to do logów, które były czytane przez pracowników biura obsługi klienta i w razie, czego wyjaśniane z klientami.

Wykorzystanie frameworka sprawiło, że zadanie trwało 30 minut, a po kilku tygodniach, incydenty z numerami spadły do strefy marginalnej. Napisanie takiej klasy od zera, byłoby bardziej czasochłonne, a tak, dodatkowo każdy przypadek był logowany. Te same testy od razu sprawdzały, czy przez przypadek na danych kontrolnych nie typujemy kogoś, kto wpisał wszystko ok. Także wtedy stworzyliśmy narzędzie dwustronne. Raz by sprawdzać czy reguły nie są szkodliwe dla uczciwych klientów, a dwa by odławiać tych nieuczciwych. Tylko raz baza była przygotowana do sprawdzania reguł, a w drugim przypadku mieliśmy komplet reguł do sprawdzania bazy.

Podsumowanie

Jeżeli nie przekonałem was do testów, sprawdźcie to na własnej skórze. Na początku bez frameworka, bo jego trzeba się nauczyć, a to chwilkę trwa. Na początku wymyślcie sobie proste zadanie i rozwiążcie go z testami, poczujcie różnice i wybierzcie z czym wam wygodnie. Moim zdaniem warto, chociaż spróbować.


Zdjęcie główne artykułu pochodzi z pexels.com.

Zapraszamy do dyskusji
Nie ma więcej wpisów

Send this to a friend