Pisząc programy nie sposób natknąć się na błędy. Najgorzej, gdy wystąpią w funkcjonalności X po tym, jak zaczniemy pracę nad funkcjonalnością Y. No i tu zaczęły się schody: nie dość, że mamy do wykonania feature Y, to jeszcze musimy naprawić X!

Czasem bywa również tak, iż programista przez swoje roztargnienie nie dopracuje „idiotoodporności” w programie i np. przy próbie dzielenia przez 0 program „wysypie się” zamiast dla przykładu wyświetlić komunikat: „pamiętaj cholero, nigdy nie dziel przez zero!”.

Kolejną niepokojącą rzeczą w życiu programisty może być fakt, że tworząc kod mógłby nie do końca być świadomy tego, co robi i w jaki sposób ma działać system, który projektuje.

Na te i wiele innych problemów może zaradzić pewna technika wytwarzania oprogramowania, znana jako Test Driven Development (TDD), czyli programowanie zorientowane testowo.

Co to jest TDD?

Podobnie, jak w poprzednich artykułach, zacznę od encyklopedycznej definicji:

Test-driven development (TDD) – technika tworzenia oprogramowania, zaliczana do metodyk zwinnych. Pierwotnie była częścią programowania ekstremalnego (ang. extreme programming), lecz obecnie stanowi samodzielną technikę. Polega na wielokrotnym powtarzaniu kilku kroków:

  1. Najpierw programista pisze automatyczny test sprawdzający dodawaną funkcjonalność. Test w tym momencie nie powinien się udać.
  2. Później następuje implementacja funkcjonalności. W tym momencie wcześniej napisany test powinien się udać.
  3. W ostatnim kroku programista dokonuje refaktoryzacji napisanego kodu, żeby spełniał on oczekiwane standardy.

Wikipedia

Jak zatem wynika z powyższego cytatu, w podejściu tym, zanim zaczniemy implementować daną funkcjonalność, najpierw tworzymy do niej test. Ktoś mógłby zapytać: „Zaraz zaraz, ale jak to, testować coś, co jeszcze nie istnieje?”. Tak, wiem, że to może się niektórym wydawać dziwne (przynajmniej na początku), ale kiedy przyjrzycie się bliżej temu zagadnieniu, zwłaszcza po dokładniejszym objaśnieniu poszczególnych kroków TDD, zrozumiecie, że takie podejście jak najbardziej ma sens i że może wpłynąć pozytywnie nie tylko na działanie programu, ale również na jakość kodu.

tdd_cykl
Kroki TDD można streścić w trzech słowach:
  1. Red
  2. Green
  3. Refactor
 

W TDD korzystamy z tzw. testów jednostkowych, czyli specjalnych kawałków kodu w programie, które odpowiadają za sprawdzenie, czy dana funkcjonalność działa, jak należy. Testy jednostkowe są pisane i utrzymywane tylko i wyłącznie w tym celu.

Jeden test sprawdza tylko jeden fragment kodu — niewielki wycinek całości systemu.

Krok Red

Zaczynamy od utworzenia testu, który nie powinien się powieść. Tutaj nawet może się nie skompilować.

Krok Green

Tworzymy kod implementujący funkcjonalność, której wcześniej zabrakło. Na tym etapie najważniejsza jest możliwie jak najszybsza implementacja spełniająca cele testu utworzonego w poprzednim kroku. Nie skupiamy się zbytnio na jakości kodu, tzn. nie musi być aż tak bardzo „idealny”. Na to przyjdzie czas w kolejnej fazie cyklu.

Następnie odpalamy test i jeśli wszystko jest na zielono, to znaczy, że przeszedł i możemy przejść do następnego kroku.

Krok Refactor

Teraz przyszedł czas na refaktoryzację (ang. refactor), czyli proces polegający na zmianie kodu niewpływający zasadniczo na jego funkcjonalność — m.in. poprawiamy jego czytelność, usuwamy duplikaty, nieużywane zmienne/metody etc.

Gdy kod zostanie odpowiednio zrefaktoryzowany, uruchamiamy testy ponownie i jeśli nadal wszystko jest zielone, wracamy do kroku pierwszego.

Jak to wygląda w praktyce?

Posłużę się tutaj bardzo prostym przykładem w języku Java . Załóżmy, że chcemy, aby metoda obliczająca pole trójkąta zwracała taką, a nie inną wartość. Najpierw trzeba utworzyć klasę testową, a w niej metodę, która będzie przeprowadzać test.

Skoro mamy do czynienia z testem w Javie, to nad metodą należy umieścić andotację @Test. Ponadto metoda testowa powinna zawierać w nazwie słowo kluczowe should.

Utwórzmy w metodzie instancję klasy, która jeszcze nie istnieje. Oczywiście skończy się to błędem — kod nawet się nie skompiluje, a o to właśnie chodzi w fazie red.

public class TriangleTest {

    @Test
    void countAreaMethodShouldReturnCorrectValue() {
        //given
        Triangle triangle = new Triangle();
    }
} 

Zwróćcie uwagę na komentarz given — oznacza on, że to, co jest pod nim należy do wstępnych założeń testu.

Przejdźmy dalej do kroku green. Teraz tworzymy brakującą klasę i jeszcze raz odpalamy test.

public class Triangle {

} 

Klasa na razie ma być bez niczego — na dalszą implementację przyjdzie czas w następnym cyklu.

Wynik testu jest zielony, więc przechodzimy dalej. Póki co, nie ma tutaj zbyt wiele do refaktoryzacji, dlatego rozpoczynamy cykl od nowa.

Teraz pod słowem kluczowym when, które oznacza operacje, których działanie chcemy przetestować, wywołujemy na obiekcie triangle metodę obliczającą pole trójkąta.

//when
int area = triangle.countArea(4, 8); 

Metoda jeszcze nie istnieje, więc test się nie powiedzie.

Przechodząc do etapu green, dodajemy brakującą metodę.

public class Triangle {

    public int countArea(int base, int height) {
        return 0;
    }
} 

Podobnie, jak w przypadku samej klasy, jeszcze nie implementujemy działania metody — na razie niech zwraca 0.

Odpalamy test: wszystko jest zielone — idziemy dalej.

Na końcu każdej metody testowej, pod słowem then, umieszczamy tzw. asercję — metodę, która musi zwrócić true, aby test się powiódł. W naszym przypadku sprawdzimy, czy funkcja po podstawieniu liczb 4 i 8 rzeczywiście zwróci 16. W języku Java, przykład asercji sprawdzającej, czy coś jest równe czemuś to assertEqualsJako pierwszy parametr podajemy wartość oczekiwaną, natomiast jako drugi — wartość, którą sprawdzamy, czy jest równa tej oczekiwanej.

//then
assertEquals(16, area); 

Z racji, iż metoda countArea zwraca 0, test nie kończy się sukcesem. Dlatego przechodzimy ponownie do fazy green i implementujemy działanie tej metody.

public int countArea(int base, int height) {
        int result = (base * height) / 2;
        return result;
    } 

Uruchamiamy test ponownie: wszystko jest już zielone. Przy okazji, można dokonać drobnej refaktoryzacji w metodzie liczącej pole, a konkretnie: usunąć zmienną result, a wynik ograniczyć tylko do działania, które zostało wcześniej podstawione pod tą zmienną.

public int countArea(int base, int height) {
        return (base * height) / 2;
    } 

I voila!  Test jest gotowy i funkcjonalność została całkowicie zaimplementowana. Całość metody testowej prezentuje się w sposób następujący:

public class TriangleTest {

    @Test
    void countAreaMethodShouldReturnCorrectValue() {
        //given
        Triangle triangle = new Triangle();

        //when
        int area = triangle.countArea(4, 8);

        //then
        assertEquals(16, area);
    }
} 

Specjalne obiekty w testach jednostkowych

Przy okazji, chciałbym tutaj opisać rodzaje specjalnych obiektów, które mogą być bardzo pomocne w tworzeniu testów jednostkowych.

Jeśli w danej klasie mamy zależność w postaci innej klasy, np. serwisu X odpowiedzialnego za połączenie z bazą danych i pobranie z niej zestawu danych, to dokładne przetestowanie metody, która swoje działanie opiera na poprawnym działaniu tego serwisu i bazy danych może być bardzo problematyczne.

Czasem może zdarzyć się też tak, że nie będziemy mieli bezpośredniego dostępu do tego serwisu i jego metod, a wtedy utworzenie dobrego testu jednostkowego będzie bardzo trudne lub wręcz niemożliwe.

W tego typu sytuacjach mogą być pomocne tzw. mocki stuby. A co to takiego? Spieszę z wyjaśnieniem.

Mock

Mock jest specjalnym obiektem, którego zadaniem jest symulacja działania prawdziwego obiektu, który ma naśladować.

Tworzenie tego typu obiektów zapewnia framework Mockito.

Załóżmy, że mamy serwis, który posiada metodę zwracającą listę pracowników, która jest pobierana z pewnego zbioru.

public class EmployeeService {

    private final EmployeeCollection employeeCollection;

    public EmployeeService(EmployeeCollection employeeCollection) {
        this.employeeCollection = employeeCollection;
    }

    List<String> getAllEmployees() {
        return new ArrayList<>(employeeCollection.getEmployees());
    }
} 

Zbiór, o którym mowa, byłby implementacją tego oto interfejsu:

public interface EmployeeCollection {

    List<String> getEmployees();
} 

Przyjmijmy, że mając zbiór pracowników, chcemy, aby metoda getAllEmployees nie zwracała pustej listy:

@Test
void getAllEmployeesMethodShouldNotReturnEmptyList() {
    //given
    List<String> employees = new ArrayList<>(List.of(
        "Grzegorz Brzęczyszczykiewicz", 
        "Jan Kowalski", 
        "Michael Slabikovsky"
    ));
    EmployeeCollection employeeCollection = mock(EmployeeCollection.class);
    EmployeeService employeeService = new EmployeeService(employeeCollection);
    when(employeeService.getAllEmployees()).thenReturn(employees);

    //when
    List<String> employeeList = employeeService.getAllEmployees();

    //then
    assertThat(employeeList, not(empty()));
} 

Na początku utworzyłem kolekcję z przykładowymi nazwiskami pracowników, następnie utworzyłem mocka oraz instancję serwisu.

Utworzenie mocka ogranicza się do użycia metody mock. Jako parametr podajemy klasę bądź interfejs, który chcemy zasymulować.

Metody when().thenReturn() zastosowałem, aby zmienić zachowanie serwisu. To właśnie tutaj wysyłana jest informacja, że wywołując taką czy inną metodę, mamy otrzymać tą bądź inną wartość. Takie założenie można również zapisać według metodyki BDD (Behaviour Driven Development) i nazw bardziej zbliżonych do naturalnego języka:

given(employeeService.getAllEmployees()).willReturn(employees); 

Asercja assertThat()jak i metody not() oraz empty() pochodzą z biblioteki Hamcrest

Stub

Stub, podobnie jak mock, jest przykładową implementacją jakiegoś kodu, którego zachowanie chcemy przetestować, jednak podstawowa różnica między stubem a mockiem polega na tym, że w przypadku tego drugiego oprócz określenia zachowania obiektu, możemy przeprowadzić jego weryfikację — tj. czy dany obiekt został użyty, jakie metody wywołano podczas przeprowadzania testu i z jakimi parametrami.

Np. gdybyśmy mieli dodatkowo zweryfikować wywołanie metody getEmployees() na implementacji interfejsu EmployeeCollection, test wyglądałby następująco:

@Test
void getAllEmployeesMethodShouldNotReturnEmptyList() {
    //given
    List<String> employees = new ArrayList<>(List.of(
        "Grzegorz Brzęczyszczykiewicz", 
        "Jan Kowalski", 
        "Michael Slabikovsky"
    ));
    EmployeeCollection employeeCollection = mock(EmployeeCollection.class);
    EmployeeService employeeService = new EmployeeService(employeeCollection);
    when(employeeService.getAllEmployees()).thenReturn(employees);

    //when
    List<String> employeeList = employeeService.getAllEmployees();

    //then
    verify(employeeCollection).getEmployees();
    assertThat(employeeList, not(empty()));
} 

To właśnie przy pomocy metody verify sprawdzamy, czy metoda rzeczywiście została wcześniej wywołana.

Wracając do stuba, oto przykład:

public class EmployeeCollectionStub implements EmployeeCollection {
    
    @Override
    public List<String> getEmployees() {
        return List.of(
            "Grzegorz Brzęczyszczykiewicz", 
            "Jan Kowalski", 
            "Michael Slabikovsky"
        );
    }
} 

Natomiast poniżej przedstawiam jego przykładowe użycie w teście:

@Test
void getAllEmployeesMethodShouldNotReturnEmptyList() {
    //given
    EmployeeCollection employeeCollectionStub = new EmployeeCollectionStub();
    EmployeeService employeeService = new EmployeeService(employeeCollectionStub);

    //when
    List<String> employeeList = employeeService.getAllEmployees();

    //then
    assertThat(employeeList, not(empty()));
} 

Dla takich prostych metod i przykładów stuby działają bardzo dobrze, jednak przy większej liczbie warunków testowych oraz przy możliwym rozroście interfejsów stuby nie są dobrym rozwiązaniem. W takiej sytuacji o wiele lepiej sprawdzają się mocki.

Spy

Chciałbym tutaj również opisać bardzo ciekawy typ obiektu, który należy do obiektów hybrydowych, tzn. mieści się gdzieś pomiędzy mockiem a prawdziwym obiektem, dzięki któremu możemy mieszać działanie metod mockowych i rzeczywistych.

Spy jest wrapperem, czyli obiektem opakowującym obiekt danej klasy, którego działanie można śledzić oraz weryfikować, podobnie jak to jest z obiektami mockowymi. Ponadto, jeżeli chcemy, to działanie jego wybranych metod możemy również mockować, wówczas obiekt Spy byłby częściowo mockiem, a częściowo normalnym obiektem.

Do przedstawienia przykładu posłużę się po raz kolejny figurą geometryczną. Tym razem będzie to prostokąt.

public class Rectangle {

    private int a;
    private int b;

    public Rectangle() {
    }

    public int getA() {
        return a;
    }

    public int getB() {
        return b;
    }

    public int countArea() {
        return getA() * getB();
    }
} 

Załóżmy, że chcemy przy pomocy obiektu spy przetestować metodę obliczającą pole prostokąta.

@Test
void countAreaMethodShouldReturnCorrectValue() {
    //given
    Rectangle rectangle = spy(Rectangle.class);
    given(rectangle.getA()).willReturn(2);
    given(rectangle.getB()).willReturn(5);

    //when
    int result = rectangle.countArea();

    //then
    then(rectangle).should().getA();
    then(rectangle).should().getB();
    assertThat(result, equalTo(10));
} 

Zacząłem od utworzenia instancji klasy Rectangle jako obiektu typu spy, następnie zaprogramowałem działanie dwóch metod obiektu w taki sposób, żeby zwracały pożądane wartości. W sekcji when wywołałem metodę countArea, która jest prawdziwą metodą. Dzięki temu, że jest to obiekt spy, możemy zweryfikować wywołanie metod, które chcemy (przy pomocy then().return()i dokonać odpowiedniej asercji.

Obiekty typu spy dobrze się sprawdzają w sytuacjach, gdy chcielibyśmy skorzystać z prawdziwego zachowania części metod w obiekcie lub zweryfikować wywołania metod zachowując ich rzeczywiste zachowanie.

TDD — wady i zalety

Wracając do techniki TDD, podejście to, jak wiele innych sposobów programowania, nie jest idealne. Wady i zalety podsumowuje poniższa tabela:

Zalety Wady
Szybkość wychwytywania błędów i zarazem redukcja kosztów wynikających z ich wyszukiwania Wymaga dodatkowej ilości czasu do utworzenia testów jednostkowych
Możliwość tworzenia bardziej przemyślanego kodu Dodatkowy wymóg utrzymywania testów
Zwiększenie szansy na czysty kod Wymuszenie tworzenia testów nie tylko podczas powstawania nowych funkcjonalności, ale również w czasie modyfikacji już istniejących
Zwiększenie świadomości programisty odnośnie tego, co robi i jak działa system, nad którym pracuje

Jak NIE należy pisać testów, czyli o antywzorcach testowych

Oto błędy popełniane przez początkujących programistów przy pisaniu testów:

  • Nastawienie na 100% pokrycia kodu testami. Z jednej strony warto do tego dążyć, ale we wszystkim trzeba znać umiar
  • Testowanie trywialnych metod typu getter/setter
  • Nieintuicyjne nieadekwatne nazwy metod testowych
  • Zbyt długo działające testy. Podstawowym założeniem testów jednostkowych jest to, że muszą być one szybkie
  • Wiele niepowiązanych asercji w teście
  • Ignorowanie testów zamiast naprawy
  • Traktowanie kodu testów jako kodu drugiej kategorii
  • Ręczne uruchamianie testów, brak automatyzacji

Podsumowanie

TDD jest techniką wytwarzania oprogramowania, która z pewnością może przynieść wiele korzyści – zarówno w działaniu programu, jak i jakości kodu. Z racji, iż to podejście oprócz zalet ma również wady, nie należy traktować go jako jedyną słuszną technikę programowania.


0 komentarzy

Dodaj komentarz

Avatar placeholder

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