Test Driven Development – TDD

Czym jest Test Driven Development

Test Driven Development to metodyka (podejście do programowania), która traktuje o testowaniu aplikacji (przy pomocy kodu) w trakcie jej tworzenia. U jej podstaw leży odwrócenie „zwyczajnego” porządku rzeczy. W TDD najpierw piszemy test (tzw. test jednostkowy) a dopiero potem implementujemy daną funkcjonalność. Samym TDD żądzą 3 kroki:
– najpierw piszemy test, który nie kończy się pozytywnie, nie przechodzi tzw. red test (błąd kompilacji lub błąd działania sprawdzanej funkcjonalności) ,
– następnie minimalną ilość kodu, która pozwala na osiągnięcie przejścia testu funkcjonowania danej metody – green test,
– jeżeli test przechodzi to zajmujemy się refaktoryzacją kodu testu oraz kodu testowanego,
– idziemy do początku, czyli piszemy znów taki test, aby nie przeszedł.

Jest to podejście iteracyjne – robimy jak najmniejsze kroki, by pójść do przodu z funkcjonalnością aplikacji. Kroki te nazywamy, jak zostało wspomniane wcześniej, RED, GREEN oraz REFACTOR i tworzą one cykl. Każdy cykl to jedna iteracja. I tak iterujemy aż zaprojektujemy daną funkcjonalność. Choć sama idea wydaje się prosta, to aby dobrze je opanować wymagane jest wiele praktyki w stosowaniu tejże metodyki.

Testy jednostkowe

W metodyce TDD do testowania aplikacji posługujemy się testami jednostkowymi. Są to kawałki kodu w programie odpowiadające tylko i wyłącznie za sprawdzanie danej funkcjonalności. Jeden test sprawdza tylko jeden fragment kodu – niewielki wycinek całości systemu. testowany fragment nazywany jest też jednostką (stąd nazwa testy jednostkowe). Może to być. np. klasa lub konkretna metoda. W kodzie danego testu występują sekcje „given”, „when” oraz „then”, które odpowiadają kolejno za: dostarczenie niezbędnych argumentów do testu, określenie warunków i sprawdzenie działania. Do sprawdzenia działania służą tzw. asercje – są to specjalne metody, które służą tylko i wyłącznie do dania wyniku, czy sprawdzana metoda spełnia nasze oczekiwania czy też nie.

Nie jest wyjątkiem to, że ilość kodu z jakiej zbudowane testy jest większa niż ilość kodu testowanej funkcjonalności – daną funkcjonalność trzeba przecież przetestować na kilka sposobów i sprawdzić wiele wariantów. Dodatkowo taki kod trzeba refaktoryzować i utrzymywać.

I wydawać by się mogło, że są to wady eliminujące takie podejście do testowania. Jednak ma ono swoje zalety – są szybkie (w ciągu milisekund uruchamianych jest wiele testów jednocześnie), możemy sprawdzić działanie pojedynczych funkcjonalności i szybciej odkryć źródło problemu i walidują na bieżąco działanie systemu, dzięki czemu od razu wychwytujemy problemy – nie trzeba później wracać do rdzenia aplikacji i kopać w kodzie poszukując problemu.

Inne rodzaje testów

W standardowym systemie posiadającą logikę biznesową, graficzny interfejs użytkownika (GUI), bazę danych oraz komunikację z zewnętrznymi serwisami mamy trzy rodzaje testów:
– jednostkowe – najliczniejsze z testów, testują tylko logikę biznesową,
– integracyjne – sprawdzają jak zachowa się nasz program (logika biznesowa) w porozumieniu z bazą danych czy jakimś zewnętrznym serwisem; są wolniejsze, bardziej pamięciożerne i jest ich mniej od testów jednostkowych; zazwyczaj uruchamia się je zazwyczaj wtedy, gdy dana funkcjonalność jest już gotowa i przechodzi ostateczne testy,
– funkcjonalne (E2E, End to End) – testują one system z poziomu użytkownika i na poziomie sprawdzania całych funkcjonalności biznesowych a nie komponentów jak klasy czy metody; do ich przeprowadzenia wymagane jest postawienia całego systemu z testy przeprowadza tester manualny lub specjalnie zaprogramowany bot, który np. „klika” po elementach strony internetowej.

TDD – praktyczne zastosowanie metodyki

W Javie (ponieważ na jej przykładzie zostanie pokazane zastosowanie TDD) do pisania testów bardzo popularną biblioteką jest JUnit. I to ją wykorzystamy w poniższych przykładach.

Kod będzie dotyczył aplikacji do zamawiania jedzenia on-line, a konkretnie, klasy repozytorium posiłków. Klasa repozytorium to taka, która przechowuje i wyszukuje obiekty. A więc zgodnie z podejściem TDD zaczynamy od stworzenia testu, który nie przechodzi (RED). Tworzymy klasę testową MealRepositoryTest oraz, przy pomocy adnotacji @Test (z bibliteki JUnit) metodę testową. Zauważmy, że w metodzie testowej znajduje się sekcja „//given” – są to elementy dostarczane do testu. Takie sekcje (pozostałe to when oraz then) to pewien standard, który ułatwia poruszanie się po kodzie testu.

public class MealRepositoryTest {

    @Test
    void shouldBeAbleToAddMealToRepository() {

        //given
        MealRepository mealRepository = new MealRepository();
    }
}

Mamy napisany test. Teraz go przeprowadzamy i sprawdzamy, jaki będzie jego wynik.

Mamy test na czerwono, ponieważ brakuje nam w ogóle klasy MealReposytory – nawet nie napisaliśmy kawałka kodu, który chcmey testować. Ale o to właśnie chodzi. Przechodzimy do fazy GREEN, czyli piszemy właściwy kod, aby test przeszedł. Dodajemy potrzebną klasę.

public class MealRepository {
}

Nie dodajemy nic wewnątrz klasy – potrzebne nam tylko tyle kodu, aby test był zaakceptowany. Wykonajmy go.

Test przeszedł. Faza GREEN zakończona. Przechodzimy do fazy REFACTOR i sprawdzamy, czy nasz kod nie potrzebuje refaktoringu. Aktualnie nie, więc pomijamy tą fazę. Pierwszy cykl zakończony. Możemy iść dalej i rozpocząć nowy cykl od fazy RED. Piszemy nieakceptowany test.

@Test
void shouldBeAbleToAddMealToRepository() {

    //given
    MealRepository mealRepository = new MealRepository();
    Meal meal = new Meal(10, "Pizza");

    //when
    mealRepository.add(meal);
}

I sprawdzamy jak to wygląda. Test nie może się skompilować, ponieważ brakuje metody add() w kodzie testowanym. Przechodzimy do GREEN – musimy spowodować, aby test się skompilował i był pozytywny. Dodajemy metodę add() do naszej testowanej klasy MealRepository.

public class MealRepository {
    
    public void add(Meal meal) {
    }
}

Metoda dodana zatem sprawdźmy, czy test będzie zielony.

Test zielony – faza GREEN zakończona. Zauważmy, że do metody add() nie dodaliśmy żadnej logiki pamiętając, że dodajemy tylko tyle kodu, aby test przeszedł. Faza REFACTOR – pomijamy, gdyż nie ma potrzeby refaktoryzacji. Idziemy dalej z kodem i rozwijamy nasz test.

@Test
void shouldBeAbleToAddMealToRepository() {

    //given
    MealRepository mealRepository = new MealRepository();
    Meal meal = new Meal(10, "Pizza");

    //when
    mealRepository.add(meal);

    //then
    assertThat(mealRepository.getAllMeals().get(0), is(meal))
}

Dodaliśmy do testu asercję oraz tzw. matchera „is”. Są one importowane z biblioteki JUnit oraz biblioteki Hamcrest. Jednak IDE podpowiada, że metoda getAllMeals() nie została w ogóle zaimplementowana we właściwym, testowanym przez nas kodzie. Dodajmy ją zatem.

public class MealRepository {

    public void add(Meal meal) {
    }

    public List<Meal> getAllMeals() {
        return null;
    }
}

I znów, wracamy do testu i sprawdzamy.

Test nie przechodzi. Zauważmy, że mamy tylko sygnatury metod, ich ciało jest puste. Dodajmy do nich logikę.

public class MealRepository {

    private List<Meal> meals = new ArrayList<>();

    public void add(Meal meal) {
        meals.add(meal);
    }

    public List<Meal> getAllMeals() {
        return meals;
    }
}

IDE informuje, że test może zostać skompilowany, ale czy uzyskamy pożądany wynik GREEN?

Test zielony, sprawdzamy czy konieczna jest refaktoryzacja. Na tym etapie nie, a więc przechodzimy do następnego cyklu. Stwórzmy teraz test sprawdzając możliwość usunięcia posiłku z repozytorium.

@Test
void shouldBeAbleToRemoveMealToRepository() {

    //given
    MealRepository mealRepository = new MealRepository();
    Meal meal = new Meal(10, "Pizza");

    //when
    mealRepository.delete(meal);

}

Jeszcze przed uruchomieniem testu IDE informuje, że test nie może zostać skompilowany, ponieważ w naszym kodzie w ogóle brakuje metody delete(). Dodajmy ją zatem, na razie bez ciała.

public class MealRepository {

    private List<Meal> meals = new ArrayList<>();

    public void add(Meal meal) {
        meals.add(meal);
    }

    public List<Meal> getAllMeals() {
        return meals;
    }

    public void delete(Meal meal) {
    }
}

Test skompilowany i po jego uruchomieniu uzyskaliśmy wynik GREEN. Tym razem możemy pokisić się o mały refaktoring. Wyciągnijmy mealRepository jako pole klasy testowej,a by nie tworzyć nowego obiektu w każdej metodzie testowej.

public class MealRepositoryTest {
    
    private MealRepository mealRepository = new MealRepository();

    @Test
    void shouldBeAbleToAddMealToRepository() {

        //given
        Meal meal = new Meal(10, "Pizza");

        //when
        mealRepository.add(meal);

        //then
        assertThat(mealRepository.getAllMeals().get(0), is(meal));
    }

    @Test
    void shouldBeAbleToRemoveMealToRepository() {

        //given
        Meal meal = new Meal(10, "Pizza");

        //when
        mealRepository.delete(meal);

    }
}

Dobrze, sprawdźmy czy nic się nie popsuło.

Testy przechodzą, kod zrefaktoryzowany, cykl zakończony. Kolejna faza RED następnego cyklu.

@Test
void shouldBeAbleToRemoveMealToRepository() {

    //given
    Meal meal = new Meal(10, "Pizza");

    //when
    mealRepository.delete(meal);

    //then
    assertThat(mealRepository.getAllMeals(), not(contains(meal)));

}

Do metody testowej dodaliśmy asercję sprawdzającą, czy repozytorium posiłków na pewno nie zawiera żadnego obiektu typu meal. Uruchamiamy test.

Test z efektem GREEN. I wydawać by się mogło, że dobrze, ale pamiętajmy, że jesteśmy w fazie RED. Wynik testu nie powinien być GREEN. A więc coś po naszej stronie podczas budowania testu zostało zrobione nie tak. Jest to jeden z ważnych punktów TDD – kod testu jest równie ważny jak kod testowanego systemu i należy na niego uważać.
Wracając do problemu – nie dodaliśmy nowego posiłku do repozytorium podczas wykonywania się testu, dlatego też repozytorium było puste i funkcjonalność usuwania posiłku z listy w ogóle nie zaszła. Zaimplementujmy więc dodanie posiłku w teście.

@Test
void shouldBeAbleToRemoveMealToRepository() {

    //given
    Meal meal = new Meal(10, "Pizza");
    mealReposytory.add(meal);

    //when
    mealReposytory.delete(meal);

    //then
    assertThat(mealReposytory.getAllMeals(), not(contains(meal)));

}

Uruchamiamy test i sprawdzamy, czy tym razem uzyskaliśmy wynik RED.

Test czerwony, a więc paradoksalnie dobrze – jesteśmy w końcu w fazie RED. Teraz dopiszmy kod umożliwiający przejście testu pozytywnie. Dodajmy logikę do metody delete().

public void delete(Meal meal) {
    meals.remove(meal);
}

I sprawdźmy teraz działanie testu.

Faza GREEN zakończona. Sprawdzamy, czy konieczny jest refaktoring kodu. Nie jest, więc cykl zakończony.

Sukcesywnie przechodzimy przez kolejne cykle testowania, jednak jest pewien problem z naszym kodem. Cały czas korzystamy we wszystkich testach z tego samego obiektu repozytorium mealRepository, który został wcześniej wyciągnięty z metod jako pole klasy testowej. To może powodować błędy w funkcjonalności testów – jeżeli np. w danym teście wymagamy, aby na liście posiłków nie było żadnego posiłku, nie uzyskujemy tego, ponieważ zalegają pozostałości w obiekcie po poprzednim teście. Dodajmy więc czyszczenie w klasie testowej, które wykonywać się będzie przed każdy testem. Wykorzystajmy do tego adnotację @BeforeEach pochodzącą z biblioteki JUnit.

@BeforeEach
void cleanUp() {
    mealReposytory.getAllMeals().clear();
}

Teraz przed każdym testem wywołujemy metodę czyszczącą listę posiłków obiektu mealRepository.

I tak właśnie rozwijamy funkcjonalność naszego systemu – przechodząc kolejne cykle składające się z faz RED, GREEN oraz REFACTOR.

Najważniejsze aspekty TDD

Powyższy przykład pokazuje praktyczne zastosowanie omawianej w niniejszym wpisie metodyki. Podsumowując, jej podstawowe założenia to:
– zacznij od czerwonego testu RED,
– napisz tylko tyle kodu, aby otrzymać test zielony GREEN,
– sprawdź, czy test lub kod budowanego systemu nie wymaga refaktoringu,
– zawsze sprawdzaj wartości graniczne.

Biblioteka Mockito – stub, mock i spy

JUnit to nie jedyna biblioteka wykorzystywana w metodyce TDD przy tworzeniu testów jednostkowych. Mockito jest frameworkiem open source, który umożliwia tworzenie specjalnych obiektów (mocków), których zadaniem jest symulacja działania prawdziwych obiektów. Jest to przydatne wtedy, gdy w danej klasie mamy zależność od innej klasy – np. serwisu lub API.

Pierwszą przydatną funkcjonalnością jest tworzenie tzw. stubów. Są to przykładowe implementacje kodu, którego zachowanie chcemy przetestować. Jeżeli nie mamy dostępu do prawdziwej metody, która będzie nam zwracała dane, to sami musimy sobie taką metodę utworzyć i napisać ją w taki sposób, żeby zwracała nam zestaw przykładowych danych. Napiszmy sobie takiego stuba (metodę stubową).

public class AccountRepositoryStub implements AccountRepository {
    @Override
    public List<Account> getAllAccounts() {

        Address address1 = new Address("Piłsudzkiego", "1");
        Account account1 = new Account(address1);

        Account account2 = new Account();

        Address address2 = new Address("Leśna", "56/14");
        Account account3 = new Account(address2);

        return Arrays.asList(account1, account2, account3);
    }

    @Override
    public List<String> getByName(String name) {
        return null;
    }
}

Krótko mówiąc, tworzymy sytuację,w której przesyłąmy z nieistniejącej bazy danych inforację dotyczącą liczby aktywnych kont i sprawdzamy, czy zgadza się ona z naszą asercją.

Widzimy jednak, że w przypadku stuba mamy tylko jeden scenariusz (zwracamy tylko jeden zastaw danych). Jeżeli chcielibyśmy np. sprawdzić w teście, co by było, gdyby nie zostały zwrócone żadne konta aktywne to byłby kłopot.

nie możemy dodać np. kolejnej metody w klasie stubowej, ponieważ opieramy się w niej na interfejsie (na zadeklarowanych w nim metodach) – a w nim mamy tylko jedną metodę. Więc, aby sprawdzić powyższy problem, należało by utworzyć nową klasę stubową a w niej zadeklarować nową metodę. Jeżeli chcielibyśmy przetestować wiele innych przypadków, to do każdego należało by utworzyć nowe klasy stubowe – coraz więcej nadmiarowego kodu, który nie jest pożądany.

Zauważmy też, co jeżeli w interfejsie zostaną zadeklarowane nowe metody – wtedy należało by je implementować we wszystkich klasach stubowych. A więc nie dość, że mielibyśmy wiele klas to mogły by one być także ciężkie w utrzymaniu.

Podsumowując, dla prostych metod i przykładów stuby działają dobrze, jednak przy większej liczbie warunków testowych oraz przy możliwym rozroście interfejsów,s tuby nie są dobrym rozwiązaniem. Tutaj bardziej sprawdzą się tzw. mocki.

Mocki to obiekty, które symulują zachowanie prawdziwych obiektów i prawdziwego kodu. Mogą być tworzone dynamiecznie podczas działania aplikacji i zapewniają znacznie większą elastyczność w porównaniu do stubów. Popatrzmy na poniższy kod.

@Test
    void getAllActiveAccounts() {
        //given
        List<Account> accounts = prepareAccountData();
        AccountRepository accountRepository = mock(AccountRepository.class);
        AccountService accountService = new AccountService(accountRepository);
        when(accountRepository.getAllAccounts()).thenReturn(accounts);

        //when
        List<Account> accountList = accountService.getAllActiveAccounts();

        //then
        assertThat(accountList, hasSize(2));
    }

private List<Account> prepareAccountData() {
        Address address1 = new Address("Piłsudzkiego", "1");
        Account account1 = new Account(address1);

        Account account2 = new Account();

        Address address2 = new Address("Leśna", "56/14");
        Account account3 = new Account(address2);

        return Arrays.asList(account1, account2, account3);
    }

Wykorzystujemy metodę mock() – jako argument dajemy klasę, której zachowanie chcemy „zamockować”. Dalej, chcąć zwrócić jakąś listę kont, posługujemy się metodą when() oraz thenReturn(). Jednak nazwy tychże metod mogą mylić się z sekcjami, nagłówkami given oraz when. Bardziej zgodna składnia z behaviour Driven Development to given() oraz willReturn().

@Test
    void getAllActiveAccounts() {
        //given
        List<Account> accounts = prepareAccountData();
        AccountRepository accountRepository = mock(AccountRepository.class);
        AccountService accountService = new AccountService(accountRepository);
        given(accountRepository.getAllAccounts()).willReturn(accounts)

        //when
        List<Account> accountList = accountService.getAllActiveAccounts();

        //then
        assertThat(accountList, hasSize(2));
    }

Możemy to przetłumaczyć tak, że jeżeli zostanie wywołana dana metoda z podanym argumentem, to zwróć dany wynik. jednak nie zawsze wiemy, z jakim argumentem dana metoda będzie wywołana. Wtedy można skorzystać z tzw. argument matchera np. any() (lub any(NazwaKlasy.class)) zamiast podawania konkretnego argumentu. Warto dodać, że jeżeli metoda przyjmuje więcej niż jeden argument, to zabronione jest mieszanie matcherów z prawdziwymi wartościami – podajemy albo odpowiednią liczbę samych matcherów albo prawdziwe wartości.

Aby utworzyć kolejny scenariusz testowy, wystarczy dodać nową metodę testową w tej samej klasie i zmienić tylko to, co chcemy zwrócić. Poniżej przypadek, gdy zwrócona zostanie pusta lista aktywnych kont.

@Test
    void getNoActiveAccounts() {
        //given
        AccountRepository accountRepository = mock(AccountRepository.class);
        AccountService accountService = new AccountService(accountRepository);
        when(accountRepository.getAllAccounts()).thenReturn(Arrays.asList());

        //when
        List<Account> accountList = accountService.getAllActiveAccounts();

        //then
        assertThat(accountList, hasSize(0));
    }

Kolejną przydatną funkcjonalnością Mockito są obiekty typu Spy. Są to obiekty opakowujące (tzw. wrappery) obiekt danej klasy, którego działanie możemy śledzić oraz weryfikować. Można powiedzieć, że jest to częściowo obiekt mockowy a częściowo „zwykły”. Dlatego na obiekty Spy często mówi się „partial mocks”. Zobaczmy jak to wygląda w praktyce.

@Test
    void testMealSumPriceWithSpy() {

        //given
        Meal meal = spy(Meal.class);

        given(meal.getPrice()).willReturn(15);
        given(meal.getQuantity()).willReturn(3);

        //when
        int result = meal.sumPrice();

        //then
        assertThat(result, equalTo(45));
    }

Najpierw tworzymy nową instancję klasy meal jako obiekt typu spy. Potem programujemy działanie dwóch metod tego obiektu – getPrice() i getQuantity(),a by zwróciły interesujące nas wartości. Kolejno, wywołujemy na tym obiekcie metodę sumProce(), która jest „prawdziwą” metodą. Dalej, dzięki temu, że jest to obiekt typu spy, możemy zweryfikować wywołanie metod i dokonać stosownej asercji.