Musisz zacząć pisać testy automatyczne!

Wstęp

Testy nie są czymś nowym i egzotycznym. Jeśli chcesz, żeby oprogramowanie, było na przyzwoitym poziomie to musisz pisać testy automatyczne. Brak jakichkolwiek testów może mieć tylko jedno zakończenie – dziury w oprogramowaniu, kosztowne wprowadzanie zmian, utrata wiedzy o działaniu oprogramowania i w efekcie zamknięcie lub przepisanie projektu. Wielu z programistów nie doświadcza jednak upadku systemu, który tworzyli – zdążyli się w porę zawinąć.

Pisanie testów ma bardzo pozytywny wpływ na cały projekt, począwszy od dobrze zorganizowanego kodu źródłowego poprzez nastroje w zespole do wysokiej jakości produktu końcowego.

Pomyślcie co by było, gdyby producenci samolotów nie testowali samolotów w różnych warunkach? Pewnie to co się stało z Boeingiem 737 MAX. Może powyższe porównanie jest trochę na wyrost bo nie każdy pisze oprogramowanie do systemów samolotu. W mniejszej skali nawet aplikacja, która jest budzikiem może ludziom zniszczyć życie: spóźnienie na samolot, do pracy, na wizytę do lekarza, opóźnione wzięcie leku etc..

Jest o wiele więcej korzyści z testowania oprogramowania, niż tylko jakość końcowego produktu i brak błędów.

Korzyści płynące z testowania oprogramowania.

1. Testy są dowodem poprawności działania kodu.

Wyobraź sobie, że przychodzi do Ciebie szef i mówi: „Jutro mamy spotkanie z klientem i chce, żebyśmy mu pokazali, że aplikacja jest w pełni sprawna – mamy na to tylko 15 min”.

W przypadku małej aplikacji, niezbyt to nas będzie przejmowało – włączymy aplikacje i ją przeklikamy. Jeśli jednak jest to aplikacja nad którą pracował kilkuosobowy zespół przez 2 lata? Jak pokazać wszystko w 15 min? Dać słowo? Wziąć L4? Zagrozić odejściem?

Jeśli projekt miałby zautomatyzowane testy akceptacyjne to wystarczyłoby wygenerować raport. Wytłumaczyć na spotkaniu czym są takie testy, uruchomić jeden test albo filmik pokazujący jak automat wyklikuje konkretny scenariusz i przekazać pełny raport z takich testów klientowi.

Wykorzystując proces BDD (Behavior Driven Development) i framework taki jak Cucumber testy mogą mieć przystępną formę nawet dla zwykłego śmiertelnika:

Feature: Background should depends on user gender
   Scenario: Female's background should be pink
     GIVEN female user
     WHEN user open app
     THEN background should be pink

   Scenario: Man background should be brown
     GIVEN man user
     WHEN user open app
     THEN background should be brown

A co jeśli Twój kolega z zespołu podejdzie do Ciebie i powie: „Kod, który mi dałeś do sprawdzenia jest bardzo skomplikowany. Czy możesz mi udowodnić, że działa poprawnie? Inaczej nie zaakceptuje go.”

Pod nosem odpowiecie „Spadaj, nie mam czasu!” a potem zaprosicie byłego już kolegę do biurka, aby uruchomić edytor i opowiedzieć o swoim „dziele”. Wiedza ta wyparuje z upływającym czasem lub zmianami w zespole – gdy ktoś w przyszłości natrafi ponownie na ten kod, odszyfrowanie może mu zająć nawet kilka godzin. Dlatego wiedzę powinniście utrwalać pisząc testy jednostkowe lub integracyjne.

Test jednostkowy testuje bardzo mały kawałek kodu – funkcję, klasę, moduł. Z niego można się dowiedzieć jak powinna działać funkcja lub klasa. Mając test na konkretny przypadek można domniemywać, że właśnie tak ma działać. Z testów jednostkowych, możemy dowiedzieć się na przykład co zwróci funkcja wyszukująca gdy nie znajdzie elementu – null czy rzuci wyjątek ElementNotFoundException.

Test integracyjny jak sama nazwa mówi – testuje integrację czyli współdziałanie kilku klas lub modułów. Dzięki takim testom, można przetestować np. działanie wszystkich klas odpowiedzialnych za parsowanie odpowiedzi serwera – przypadki gdy JSON nie jest poprawny, pusty lub występują błędy HTTP

Dobry test musi być dobrze nazwany i czytelny – dzięki temu inni programiści nie muszą się zastanawiać co autor miał na myśli i czy na pewno test jest dobrze napisany.

    //ŹŁY TEST
    @Test
    fun `test error message`() {
        screenLauncher.launch("third")
        errorLauncher.verifyLaunched()
        firstLauncher.verifyNotLaunched()
        secondLauncher.verifyNotLaunched()
    }

    //DOBRY TEST
    @Test
    fun `should show error screen when screenName is not correct`() {
        //GIVEN
        val wrongScreenName = "wrong name"
        //WHEN
        screenLauncher.launch(wrongScreenName)
        //THEN
        errorLauncher.verifyLaunched()
        firstLauncher.verifyNotLaunched()
        secondLauncher.verifyNotLaunched()
    }

Jeśli twoje oprogramowanie takiego zestawu testów nie posiada to napisanie kilku tysięcy takich testów nie jest rzeczą trywialną. Jednak nigdy nie jest za późno i jeśli projekt ma perspektywy na dalszy rozwój to najlepszy moment aby zacząć je pisać to właśnie TERAZ!

2. Testy zabezpieczają kod przed niepowołanymi zmianami.

Nie będziesz miał w pływu na zmiany w kodzie podczas Twojej nieobecności, gdy przejdziesz do innego projektu, projekt zostanie sprzedany albo odejdziesz z pracy. Mniej lub bardziej doświadczeni programiści będą tam grzebali. Gdy coś w nim zepsują, nikt się do tego nie przyzna – wina spadnie na Ciebie jako mitycznego poprzednika i głównego twórcę kodu. Nikomu nie będzie się chciało grzebać w historii kontroli wersji aby znaleźć osobę, która zmieniła jedną linijkę albo znak.

Aby zabezpieczyć kod przed niebezpieczną ingerencją innych osób, musisz pisać do niego testy. Jeśli ktoś zepsuje twój kod to Twoje testy nie przejdą i taki błąd zostanie szybko wykryty (o ile testy są uruchamiane). Oczywiście ktoś, kto ma wystarczający tupet może zmienić lub co gorsza wyłączyć testy. Takiego delikwenta można jednak łatwo znaleźć i odpowiednio się nim zająć 🧨.

Testuj swój kod i chroń swoje dobre imię, pozostawiony kod to Twoja wizytówka, która nigdy nie zniknie. Są osoby, które pracują po kilka miesięcy w projekcie, zostawiają syf i uciekają – nie bądź taką osobą. Ta branża jest na tyle mała, że po 10 latach takiego podejścia można zostać bardzo znany.

3. Pisz testy, nie dokumentację

W korporacjach możecie się spotkać z wymogami prowadzenia dokumentacji projektowej. Spotkałem się raz z takim tworem, który zawierał zrzuty ekranu i opisy typu: „po przyciśnięciu przycisku otwiera się okno z listą produktów”. Po zmianie wymagań albo zmianie kolorów, trzeba było zrobić od nowa kilkadziesiąt zrzutów, ponieważ dokumentacja nie była spójna z projektem 🤯

Zamiast marnować czas na robienie zrzutów ekranów, których nikt nigdy nie będzie oglądał to lepiej poświęcić ten czas na pisanie testów aplikacji, które z wykorzystaniem frameworka Cucumber mogą stanowić świetną dokumentację:

Feature: Open product list
   Scenario: User can see all product list
     GIVEN user
     WHEN click on products button
     THEN open list with all products

   Scenario: User can't see product list without internet
     GIVEN user without internet connection
     WHEN click on products button
     THEN open internet connection error

Drugą straszną praktyką z jaką się spotkałem było pisanie dokumentacji Javadoc do wszystkich publicznych metod. Jak się pewnie część z Was domyśla w pewnym momencie sprowadzało się to do pisania komentarzy do getterów i setterów:

  /**
   * Getter for user
   * @return User
   */
  public User getUser(){
    return user
  }

Robert C. Martin w swojej książce „Czysty Kod [eng. Clean Code]” napisał, że dobry kod nie musi być dokumentowany komentarzami, prawidłowe nazwy klas i metod już stanowią dokumentację kodu. Jednak sama nazwa funkcji nie zawiera informacji o sposobie jej działania : fun setName(name : String?) a nadwyrężanie nazwy po to aby zamieścić więcej informacji doprowadzi do patologii: fun setNameIfNotNullOrSetDashIfEmptyOrNull(name : String?) 🤮(O ile nie jesteś programistą Objective-C  na pewno się ze mną zgodzisz, że wygląda to okropnie).

Więcej informacji o sposobie działania kodu zmieści się w testach jednostkowych. Spójrz na poniższy przykład, w którym znajdują się same nazwy testów dla metody fun setName(name : String?) :

@Test 
fun `should set name when not null and is not empty`(){ ... } 
@Test 
fun `should set dash when name is empty`(){ ... } 
@Test 
fun `should set dash when name is null`(){ ... }

Z nazw testów można dowiedzieć się dokładnie jakie jest prawidłowe zachowanie testowanej metody. Testy jednostkowe dzięki swojej szczegółowości są świetną dokumentacją kodu, która w dodatku automatycznie weryfikuje jego poprawność.

4. Wykrywanie regresji w oprogramowaniu

Regresją w programowaniu nazywamy pojawienie się błędu po wprowadzeniu nowej funkcjonalności, refaktoringu lub naprawie innego błędu z zupełnie innym miejscu. Regresje są jednym z większych problemów w źle zaprojektowanych systemach, ponieważ nie posiadając solidnego zestawu testów nie jesteśmy w stanie tego wykryć przed uruchomieniem aplikacji na produkcji. Takie błędy mogą istnieć miesiącami a nawet latami, do momentu zgłoszenia takiego błędu przez użytkownika (o ile mamy dobry kanał komunikacji pomiędzy użytkownikami).

Regresję wykrywają testy integracyjne lub akceptacyjne, ponieważ do błędów dochodzi na poziomie integracji klas lub modułów. Są jednak przypadki w których testy jednostkowe mogą wykryć regresję np. zmiana zwracanego typu przez funkcję np. z float magicQuantity() na int magicQuantity(). Problem pojawi się gdy w kodzie znajdzie się poniższe wyrażenie:

float a = 8/magicQuantity();

w przypadku gdy int magicQuantity() zwróci 16 otrzymamy wynik aa=0 zamiast a=0.5. Podczas kompilacji nie zobaczymy żadnego błędu, ponieważ nastąpi automatyczne rzutowanie.  

5. Bezpieczna refaktoryzacja

Refaktoryzacja kodu bez dobrego pokrycia testami jest obarczona dużym ryzykiem. Mała klasa może mieć ok 4-10 przypadków testowych. Jeśli w systemie znajduje się 100 małych klas, które mają po zaledwie 4 przypadki testowe to w całości systemu mamy minimum 400 takich przypadków testowych – istnieje bardzo mała liczba ludzi którzy potrafiliby to zapamiętać, dlatego refaktoryzowanie kodu bez testów nad którym pracowaliśmy dawniej niż kilka dni temu będzie generowało błędy i dodatkowe koszty.

Na kodzie, który jest dobrze pokryty testami możemy wykonywać dowolne operacje do momentu gdy przechodzą wszystkie testy. Możemy się skupić na usprawnieniu działania i wyglądzie nie martwiąc się, że kod przestanie działać. Po prostu gdy coś zepsujemy testy pokażą dokładnie co nie działa. Z testów dowiemy się jak dana klasa ma działać i na co zwracać uwagę. Nie musimy poświęcać swojego czasu i innych aby odtworzyć wymaganie klasy.

Oczywiście nie zawsze jest tak pięknie, testy czasami nie pokrywają wszystkich możliwych przebiegów. Przed refaktorowaniem należy także sprawdzić czy testy nie zabetonowały kodu (czyli uniemożliwiły jakąkolwiek zmianę) – jeśli tak, to należy je najpierw poprawić. Wychodzę z założenia, że lepsze są słabo napisane testy ale poprawne testy niż żadne. Oczywiście bardzo słabe testy mogą skutecznie utrudnić dalszą pracę, ale jeśli mają chociażby dobre nazwy (patrz pkt. 4), to zawsze możemy je poprawić. Najważniejsze, żeby wiedza o sposobie działania klasy nie zginęła.

6. Zmniejszone koszty utrzymania

Częstym powodem zmiany pracy w IT jest zbyt duża ilość pracy przy utrzymaniu oprogramowania w stosunku do implementacji nowych funkcjonalności. To jest oficjalny powód a w rzeczywistości jest to ucieczka przed odpowiedzialnością za narobiony bałagan – przez Ciebie albo przez Twoich kolegów. Wszyscy przecież chcą pracować w nowym projekcie, z dowolnością w wyborze technologii i z brakiem jakiegokolwiek utrzymania.

Nie jest to niestety możliwe, ale można zminimalizować ilość pracy na utrzymanie poprzez wprowadzenie polityki testowania swojego kodu. Gdy kod jest dobrze przetestowany utrzymanie sprowadza się do naprawy bugów, których jest mało. Jeśli jest wysoka kultura testowania w firmie to naprawa błędu zamyka się w 2 krokach:

  1. napisz test, który replikuje błędne zachowanie
  2. spraw aby test przeszedł

W większości przypadków obchodzi się bez użycia debuggera.

7. Zabezpieczenie przed konsekwencjami prawnymi

To punkt głównie przeznaczony dla freelancerów i osób zarządzających projektami.

W umowie może być zapis, że zlecenie zleceniobiorca musi wykonać zlecenie stosując dobre praktyki wytwarzania oprogramowania i z należytą starannością. Dosyć lakoniczne stwierdzenie może być powodem problemów prawnych – gdybym był biegłym sądowym w sprawie oprogramowania to w przypadku brakujących testów, na pewno byłoby to na niekorzyść autora kodu chyba, że w umowie jasno byłoby napisane, że klient nie chce testów.

Pozytywne testy jednostkowe, integracyjne i akceptacyjne stanowią o poprawności działania aplikacji – to co pisałem w pierwszym punkcie. Raport z testów może stanowić załącznik do protokołu odbioru oprogramowania co zabezpiecza nas przed późniejszymi roszczeniami z drugiej strony.

Każda zmiana testów w historii repozytorium i dobrze opisane commity w gicie pozwolą na późniejsze udowodnienie np. zmieniających się wymagań ze strony klienta. Jeśli zdarzy się zmiana wymagań to warto w commicie dodać na przykład taką informację: „Requirement changed: Change login button to blue” i ewentualnie dodając numer taska.

8. Rozwiązywanie trudnych problemów

Zdarza się problem, który jest na tyle skomplikowany, że nie wiadomo, z której strony zacząć, jakie klasy stworzyć, jak je połączyć. Logika takiego problemu jest na tyle duża, że nie mieści się w podręcznej pamięci mózgu i programista zaczyna stosować monkey development – czyli pisania różnych przypadkowych rozwiązań, często ctrl+v ze stackoverflow i sprawdzania czy zadziała.

Technika TDD (Test Driven Development) pozwala na praktycznie natychmiastowe zabranie się za programowanie i rozwiązywanie problemów małymi etapami. Każdy mały etap zawiera 3 kroki:

  1. napisanie testu
  2. napisanie kodu spełniającego test
  3. refaktoryzację

W momencie gdy coś zepsujesz, to będziesz mógł się cofnąć do poprzedniego miejsca, w którym kod działał. Te cofnięcie będzie bardzo małe, ponieważ piszesz test za testem, linijkę za linijką – usuniesz co najwyżej kilka linii kodu i stracisz co najwyżej kilka minut.

Oczywiście istnieje ryzyko, że zabetonujesz się i dojdziesz do martwego punktu z którego będzie trudno Ci wyjść ale wtedy możesz zrefaktorować lub przepisać całkowicie kod ale będzie trochę łatwiej, ponieważ  masz już do niego testy.

9. Tworzenie lepszej jakości kodu

Testy oprócz zminimalizowania ilości błędów i zabezpieczania przed nimi mają jeszcze inną wielką zaletę. Ponieważ trudno jest pisać testy do kodu, który nie spełnia zasad SOLID to wpływają one na jakość kodu. Dlatego jeśli stosuje się TDD (Test Driven Development) to testy wymuszają pewne konstrukcje i cechy:

  • Klasy są mniejsze. Duże klasy powodują lawinowe zwiększenie się przypadków testowych. Stosując TDD można szybko zauważyć, że liczba odpowiedzialności jest zbyt duża i wcześnie wynieść funkcjonalność do osobnej klasy.
  • Klasy mają mniej zależności. Pisanie testów dla klas, które mają więcej niż 2-3 zależności jest uciążliwe – zbyt dużo mocków, zbyt dużo testów.
  • Większość zależy od interfejsów – odwrócenie zależności. Testowanie klas które mają zależności od konkretnych klas albo systemowych takich jak Context File itp. jest uciążliwe, wolne a czasami nawet niemożliwe do przetestowania, dlatego pisząc testy dla ułatwienia chowamy je za interfejsami. Korzyści płynące przy późniejszych zmianach są nie do ocenienia.
  • W kodzie pojawiają się wzorce projektowe, ponieważ wykorzystanie ich ułatwia zazwyczaj pisanie testów. Najczęściej jest to chyba wzorzec Factory i Strategy.
  • Dzięki zastosowaniu TDD można uniknąć skomplikowanej i rozrośniętej ifologii.

10. Oszczędność czasu = oszczędność pieniędzy

Najbardziej kontrowersyjny punkt zostawiłem na koniec. Sam nie za bardzo w to wierzyłem kilka lat temu do momentu gdy nie wprowadziliśmy w nowym projekcie polityki pisania testów na różnych poziomach – unit testy, integracyjne i UI (akceptacyjne na poziomie aplikacji mobilnej)

Bardzo dużo osób słysząc, że piszę w aktualnym projekcie testy jednostkowe, integracyjne i akceptacyjne reaguje: „U nas w projekcie nie ma czasu”, „Macie dobrze, że macie tyle czasu”, „U nas nikt by na to nie pozwolił, ledwo wyrabiamy się z bieżącą pracą”, „Jak znajdujecie czas na pisanie i utrzymywanie testów?”. A jak Wy znajdujecie czas na utrzymywanie kodu bez testów?.

Jest sporo miejsc, w których posiadanie testów pozwala zaoszczędzić czas:

  • Zredukowanie liczby uruchomień aplikacji. Gdy mam kod, który nie jest powiązany z UI to mogę przetestować ok 20 przypadków w mniej niż kilkanaście sekund. W przypadku testów UI lub akceptacyjnych (testy espresso) mogę przetestować ok 10-20 scenariuszy (jeden ekran) w mniej niż 1 min a kilkaset scenariuszy w mniej niż 10 min. Uruchamiam aplikację, żeby sprawdzić czy działa może raz na godzinę. Kiedyś kilkaset razy w ciągu dnia.
  • Wykrywalność błędów jest praktycznie natychmiastowa, więc nie trzeba ich szukać. Jeśli zaś jakiś wystąpi najpierw piszę test potem szukam miejsca, w którym test się załamuje i naprawiam błąd. (nie wszystkie błędy da się w ten sposób znaleźć, niektóre nie są do zreplikowania i zależą mocno od platformy ale jest ich niewiele)
  • Zredukowanie używania debugera. Debugera używam jedynie gdy testy przechodzą a nie powinny lub odwrotnie – czasem zdarzy się błąd w testach. Włączam go w miejsch gdzie nie da się napisać testów bądź jest to zbyt kosztowne lub skomplikowane. Są to jednak bardzo rzadkie sytuacje i uruchamianie debugera jest czynnością raczej sporadyczną.
  • Na refaktorowaniu – w czasach gdy nie pisałem testów, refaktorowanie zawsze zaczynało się od dekodowania kodu (zazwyczaj własnego), rozkopaniem  całego projektu a kończyło się masą błędów i tygodniem w plecy. W tej chwili jak zajrzę do testów wiem co dany kawałek kodu ma robić a uruchamiając testy po każdej mniejszej zmianie mam pewność, że nic nie zepsuję. Wcześniej refaktorowałem kod jak rzeźnik używając tasaka i siekiery, dzisiaj dążę do chirurgicznej precyzji.
  • Na szukaniu wymagań – jeśli chce wiedzieć jak powinien działać jakiś element aplikacji po prostu patrzę do testów espresso. Mamy tam opisane dokładnie jakie powinno być działanie konkretnych ekranów.

Podsumowanie

I tak zebrało się 10 powodów dla, których powinieneś testować swój kod. Ale czy powinieneś czy raczej musisz? Jeśli chcesz być profesjonalistą to musisz testować swój kod.

Testy traktuję jak kod produkcyjny, tak samo o niego dbam, stanowi on jedność z kodem aplikacji. Nikogo nie pytam czy mogę pisać testy, to tak jakbym się zapytał czy mogę napisać klasę lub funkcję. Testy są przede wszystkim dla mnie i zespołu. Dzięki nim wszyscy wiemy jak ten kawałek kodu działa. Robiąc zmiany w przetestowanym kodzie nie obawiam się, że coś zepsuję.

Testy nie są złotym środkiem na wszystkie problemy, one też postawią was przed nowymi problemami i wyzwaniami. Źle napisane lub zarządzane testy będą jak wrzód na tyłku w projekcie. Do sukcesu potrzebne jest stopniowe wdrażanie testów, wytrwałość do tworzenia testów mimo napiętych terminów, solidność do pisania dobrych niezależnych testów i rozsądek do racjonalnego wyboru miejsc w których taki test możemy napisać.

Niekiedy napisanie testu pochłonie mnóstwo czasu a korzyści będą mierne. Przykładem może być chęć rzucenia hasła „zostawmy nowe funkcjonalności i piszmy testy”. Nie! To spowoduje stagnacje projektu i chęć przeorania całego kodu co przełoży się na olbrzymie koszty a nawet upadek projektu. Jak nasz system nie ma testów to zacznijmy je pisać do nowych funkcjonalności. Jeśli ruszamy jakąś starą funkcjonalność to napiszmy do niej test! Nie zapędzajmy się i nie piszmy i nie refaktorujmy od razu całego modułu – jeśli działał do tej pory to niech działa. Róbmy to powoli, małymi krokami zdobywając w tym doświadczenie na własnym projekcie i ucząc się na małych błędach. Jeśli nie podołamy podczas 3 godzinnej sesji to zrobimy git reset --hard i pogódźmy się z porażką. Jeśli jednak nie podołamy po miesięcznym maratonie to co wtedy powiemy przełożonym? „Na blogu wyczytałem, że miało być tak pięknie”?

Czy nadal się czujesz na tyle pewnie, żeby ręczyć ze 100% pewnością za swój nieprzetestowany kod? Musisz pamiętać, że w kilkudziesięciu tysiącach linii kodu zawsze jest gdzieś miejsce na potencjalny błąd – najczęściej związany z integracją lub synchronizacją. Przed wdrożeniem oprogramowania porządny zbiór testów pozwoli na bezpieczne opublikowanie oprogramowania minimalizując wystąpienie błędu.

Jeśli jesteś PM’em lub właścicielem firmy, która tworzy oprogramowanie i zależy Ci na jakości i bezawaryjności to musisz sobie odpowiedzieć na bardzo ważne pytanie: Czy na pewno stać Cię na utrzymywanie kodu bez testów?

Jeśli uważasz treść za wartościową to podziel się nią z innymi. Dziękuję.

Mateusz Chrustny

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *