Test Driven Development

TDD jest to technika tworzenia oprogramowania, polegająca na rozpoczęciu pisania kodu od napisania testu, a następnie stworzenia funkcjonalności, która spełni napisany wcześniej test. Z początku może się to wydawać dziwne, nieintuicyjne, ale pisanie programu tą techniką daje wiele korzyści, dzięki czemu wiele zespołów korzysta z TDD.

Co to są testy jednostkowe?

Zanim zagłębimy się w opis metody tworzenia oprogramowania TDD odpowiedzmy sobie na pytanie po co właściwie testować nasz kod. Otóż zawsze kiedy tworzymy funkcjonalności musimy się upewnić, że działają one w sposób jaki chcemy żeby działały. Z tego zdania wynika, że testy nie uchronią nas od błędów. Jeżeli programista źle nakreśli założenia, to testy będą przechodzić, a mimo to aplikacja nie będzie działała poprawnie. Jednak w większości przypadków testy są nieocenioną pomocą i pozwalają zaoszczędzić czas na odnajdowanie drobnych pomyłek, które wkradły się w kodzie. Tak więc w metodzie TDD wykorzystuje się testy jednostkowe tzn. takie, które pozwalają na niezależne testowanie niewielkich wycinków kodu. Mogą to być pojedyncze metody, klasy, nieraz całe pakiety. Testy jednostkowe sprawdzają czy taki wycinek kodu zachowuje się w ściśle określony przez programistę sposób. Są szybkie, automatyczne, powtarzalne i niezależne od maszyny czy środowiska, na którym są wykonywane.

W języku Java bardzo popularnym narzędziem do obsługi testów jednostkowych jest framework JUNIT dostępny obecnie w wersji 5. Aby dodać go do projektu Mavenowego należy do pliku pom.xml dodać kilka zależności i wtyczek. Poniżej fragment pliku pom.xml

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>${maven.compiler.source}</maven.compiler.target>
        <junit.jupiter.version>5.5.2</junit.jupiter.version>
        <junit.platform.version>1.5.2</junit.platform.version>
    </properties>
 
    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-runner</artifactId>
            <version>${junit.platform.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
 
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
            </plugin>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

Frejmwork JUNIT dostarcza programiście wiele możliwości testowych. Głównie są to adnotacje testowe oraz asercje, czyli metody, które sprawdzają czy test poszedł po myśli czy też nie przeszedł. Asercje te pozwalają testować m.in. równości wartości obiektów, wartość null, kolekcje, wyjątki i wiele innych. Adnotacje natomiast pozwalają oznaczać metody, które mają wykonać się przed wszystkimi testami lub przed każdym pojedynczym testem, pozwalają wyłączyć jakiś test jednostkowy, albo specjalnie go oznaczyć. Aby poprawić czytelność asercji możemy korzystać z biblioteki zewnętrznej dostarczającej specjalne metody tzw. matchery. Wówczas korzystamy głównie z metody assertThat() i przekazujemy jej odpowiednie matchery np z biblioteki hamcrest lub asertJ. Omawianie wyżej wymienionych bibliotek jest jednak materiałem na osobny artykuł, więc zainteresowanych odsyłam do przejrzenia internetu w celu poszerzenia wiedzy na ten temat.

Przykładowy test jednostkowy

Wyobraźmy sobie, że tworzymy jakąś aplikację i posiadamy w niej klasę Account. Klasa ta posiada pole prywatne typu boolean o nazwie active. W momencie utworzenia konta chcemy aby było ono nieaktywne, a zatem wartość pola active musi być false. Ponadto utworzymy w naszej klasie gettera pola active oraz metodę activate. Spójrzmy na kod poniżej.

public class Account {

    private boolean active;
    
    public Account() {
        this.active = false;
    }

    public void activate() {
        this.active = true;
    }

    public boolean isActive() {
        return this.active;
    }

}

Teraz kiedy chcielibyśmy przetestować czy po utworzeniu konto rzeczywiście jest nieaktywne, możemy wykonać następujący test:

public class AccountTest {

    @Test
    public void newAccountShouldNotBeActive() {
        Account newAccount = new Account();

        assertFalse(newAccount.isActive(), "Newly created account is not activated");
    }
}

Jak widać należy utworzyć tożsamą klasę testową dla klasy testowanej i umieścić ja w odpowiednim miejscu projektu. Należy stworzyć taką samą paczkę tylko dla odmiany nie w katalogu main ale w katalogu test. Nazwa klasy powinna mieć dopisek Test na końcu. Nazwy testów powinny od razu mówić co dany test sprawdza. Jeżeli wynik tego testu będzie poprawny to wszystko jest w porządku (zielony test), jednak jeżeli konto będzie aktywne to test nie przejdzie (czerwony test).

Mockito

Test, który zaprezentowałem jako przykład jest trywialny. W prawdziwym życiu zdarzają się jednak dużo bardziej skomplikowane sytuacje i metody do testowania. Przykładowo możemy mieć metodę, która komunikuje się z jakimś web service’m albo bazą danych i przetwarza odpowiedź, którą stamtąd uzyska. Na etapie testów nie mamy jednak możliwości, ani czasu na to by fizycznie takie prawdziwe dane uzyskać. Musimy jednak dostarczyć odpowiednie zależności naszym obiektom, żeby w ogóle móc je utworzyć. Zerknijmy na poniższy kod.

class AccountService {

    private AccountRepository accountRepository;

    AccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    List<Account> getAllActiveAccounts() {
        return accountRepository.getAllAccounts().stream()
                .filter(Account::isActive)
                .collect(Collectors.toList());
    }
}

Wyobraźmy sobie sytuację, gdzie posiadamy klasę obsługującą konta na jakimś portalu. Nazwijmy ją AccountService. Klasa ta posiada pole AccountRepository, które jest interfejsem posiadającym metodę getAllAccounts(). Nasz AccountService ma metodę zwracającą wszystkie aktywne konta. Aby jednak mogła to zrobić musi dostać wszystkie konta od obiektu accountRepository. I tutaj tworzy się problem, gdyż nie mamy faktycznie dostępu do żadnej bazy danych takich obiektów typu Account. Ten problem możemy rozwiązać na kilka sposobów. Po pierwsze utworzyć obiekt typu Stub. Jest to obiekt, który dostarcza nam jakiś przykładowy zestaw danych, który zawsze jest identyczny. Np może być to implementacja interfejsu AccountRepository, którego metoda getAllAccounts zwraca zawsze 3 konta, 2 posiadające adres i jedno nowe jeszcze nieaktywne.

public class AccountRepositoryStub implements AccountRepository {

    @Override
    public List<Account> getAllAccounts() {
        Address address1 = new Address("Kwiatowa", "33/5");
        Account account1 = new Account(address1);

        Account account2 = new Account();

        Address address2 = new Address("Piekarska", "12b");
        Account account3 = new Account(address2);

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

Taki obiekt pozwala na przetestowanie metody w następujący sposób.

    @Test
    void getAllActiveAccounts() {

        //given
        AccountRepository accountRepositoryStub = new AccountRepositoryStub();
        AccountService accountService = new AccountService(accountRepositoryStub);

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

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

    }
}

Jest to najprostszy sposób na dostarczenie zależności dla obiektu AccountService. Jest to jednak sposób mało elastyczny, gdyż pozwala sprawdzić tylko jeden scenariusz. Jeżeli chcielibyśmy np sprawdzić jak zachowa się metoda getAllActiveAccounts w sytuacje gdy w puli nie będzie żadnego konta aktywnego to musielibyśmy stworzyć nową klasę Stub. Dlatego do tego typu testów niezbędne są mocki dostarczane przez bibliotekę mockito. Mocki to obiekty zastępujące obiekty danej klasy. Jako, że nie są to prawdziwe obiekty to i metody na nich wywoływane nie są prawdziwe. Zachowują się one w taki sposób, że zwracają jakąś sensowna wartość. Np jeśli mockowana klasa ma metodę zwracającą inta to wywołanie jej na mocku zwróci 0. dla jakiejś listy będzie to pusta lista itp. Spójrzmy jak można poradzić sobie z tym samym testem za pomocą mocka.

    @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));

    }

kluczową rolę odgrywa tu linia kodu given will return. Mówi ona co ma zwrócić metoda getAllAccounts kiedy zostanie wywołana. W ten sposób możemy przygotować sobie wiele scenariuszy bez konieczności tworzenia wielu nowych klas Stubowych. Wystarczy do obiektu accounts przypisać za pomocą metod pomocniczych jakąś interesującą nas wartość. Można więc pomyśleć, że obiekty typu mock załatwiają sprawę. Jest jednak z nimi pewien problem. Każda metoda, która nie zostanie ujęta w bloku given will return, w momencie wywołania zwróci tylko domyślną wartość dla typu, który zwraca. Zaś czasami chcemy, żeby zachowała się jak prawdziwa metoda. Wówczas porzebny nam jest obiekt pośredni pomiędzy mockiem i stubem. Takim obiektem jest Spy. Do obiektu Spy można przypisać za pomocą instrukcji given will return pewne zachowania, a Jeżeli inne metody będą na nich bazować i będą tylko ich kombinacją to będzie można je wywołać tak jak prawdziwe metody na realnych obiektach. Zobaczmy przykład

    @Spy
    private Meal mealSpy;

Powyżej deklaracja obiektu typu Spy na samym początku klasy, a poniżej kod testu:

    @Test
    @ExtendWith(MockitoExtension.class)
    void testMealSumPriceWithSpy() {

        //given
        given(mealSpy.getPrice()).willReturn(15);
        given(mealSpy.getQuantity()).willReturn(3);

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

        //then
        then(mealSpy).should().getPrice();
        then(mealSpy).should().getQuantity();
        assertThat(result, equalTo(45));

    }

Widzimy, że w bloku //when wywoływana jest prawdziwa metoda z klasy Meal, która to metoda korzysta z metod pomocniczych getPrice() i getQuantity(). Po określeniu zachowań tych dwóch metod można śmiało wykonywać prawdziwą metodę sumPrice(). Jak widać Spy jest to coś w rodzaju połączenia Mocka ze Stubem i w pewnych sytuacjach znajduje to swoje zastosowanie.

TDD w praktyce

Po dość długim wstępie pora przejść do omówienia na czym polega programowanie techniką Test Driven Development. TDD jest to technika tworzenia oprogramowania oparta na ciągłym powtarzaniu cyklu, który w skrócie można nazwać red – green – refactor.

Polega to na tym, że zaczynamy wprowadzanie nowych funkcjonalności od napisania niedziałającego testu (faza red). Następnie piszemy najmniejszą ilość kodu wystarczającą do tego aby test przeszedł (faza green). Niekiedy może być to nawet sygnatura metody. Potem przechodzimy do refactoringu, a zatem poprawienia jakości kodu i znowu piszemy fragment kodu do momentu nie przechodzenia testu (ponownie faza red.), znowu piszemy najmniejszą możliwą ilość kodu by test przeszedł i znowu refactorujemy. Bardzo ważne jest, że naprawiamy kod natychmiast jak tylko test nie przechodzi. Im mniejsze kroki będziemy robić tym większa szansa, że wszystko będzie funkcjonowało jak należy. Spójrzmy teraz na przykładowe zaimplementowanie nowej funkcjonalności metodyką TDD. Wyobraźmy sobie, że chcemy utworzyć klasę MealRepository służącą przechowywaniu i wyszukiwaniu posiłków. Na początku będziemy chcieli zaimplementować funkcjonalność dodawania do niej posiłków. Zaczynamy więc od utworzenia klasy testowej i napisania czerwonego, niedziałającego testu.

public class MealRepositoryTest {

    MealRepository mealRepository = new MealRepository();

}

W tym momencie natrafiamy na pierwszy etap naszego testu. Kod się nie kompiluje ponieważ nie mamy jeszcze klasy MealRepository. Przechodzimy zatem do fazy zielonej, tworzymy klasę MealRepository.

public class MealRepository {
}

W tym momencie błąd kompilacji jest wyeliminowany przy najmniejszej możliwej ilości napisanego kodu. Jesteśmy w fazie zielonej. Nie ma w tej chwili nic do refactoringu zatem znowu napiszemy fragment testu, który wprowadzi nas w fazę czerwoną.

    @Test
    void shouldBeAbleToAddMealToRepository() {

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

        //when
        mealRepository.add(meal);
    }

Teraz należy napisać sygnaturę metody add() w klasie MealRepository, aby przejść do fazy zielonej

public class MealRepository {

    public void add(Meal meal) {

    }
}

Jesteśmy w fazie zielonej. Nie ma nic do refactoringu, więc należy dopisać kolejny kawałek testu.

    @Test
    void shouldBeAbleToAddMealToRepository() {

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

        //when
        mealRepository.add(meal);

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

    }

Teraz aby spełnić działanie asercji trzeba napisać trochę kodu (pamiętajmy jednak, że ma to być minimalna ilość kodu!!!). Należy dodać do klasy MealRepository pole typu List<Meal>, a także stworzyć logikę dodającą do tej listy posiłek. Ponadto należy napisać gettera zwracającego listę.

public class MealRepository {

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

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

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

Powyższy kod doprowadza do spełnienia asercji i przejścia do fazy zielonej. Jest to na tyle prosta funkcjonaloność, że na ten moment nie wymaga dalszego refactoringu. Można jedynie pokusić się o wyczyszczenie obiektu mealRepository po zakończonym teście, żeby nie wpływał on na następny test. w tym celu można posłużyć się adnotacją @AfterEach. Test jest ukończony. Zatem udało się zaimplementować funkcjonalność dodawania posiłku do MealRepository metodą TDD.

Podsumowanie

Testy jednostkowe to bardzo potężne narzędzie programistów. Pozwalają one sprawdzać czy pisana przez developera funkcjonalność działa zgodnie z jego intencją. Są one wykorzystywane w wielu metodach tworzenia oprogramowania jak np. Test Driven Development. TDD jest bardzo ciekawą techniką pisania oprogramowania. Odwraca ona bowiem sposób pisania kodu i wprowadzania do aplikacji nowych funkcjonalności. Mianowicie zaczynamy od testu, a dopiero potem wprowadzamy do niego pewną funkcjonalność tak aby spełniała ona test. Ma to swoją wielką zaletę. Pozwala bardzo szybko wykrywać błędy i je poprawiać, przed wprowadzeniem kodu na produkcję. Przy dużych projektach pozwala to zaoszczędzić sporo czasu i nerwów. Daje też na większą kontrolę nad aplikacją. Nie zawsze jednak należy stosować TDD. Jeżeli tworzymy prosty program to podejście to może nie przynosić żadnych dodatkowych korzyści i jest tylko stratą czasu. Należy zawsze pamiętać, żeby korzystać z narzędzi odpowiednich do rozwiązywanego problemu.