Test Driven Development (TDD)

Spis rzeczy:

  1. Co to jest i do czego służy?
  2. Przykładowy kod…
  3. Stub, Spy oraz Mock
  4. Zalety i wady TDD
  5. Podsumowanie

TDD – to taka technika tworzenia oprogramowania, w której nacisk kładzie się na pisanie przypadków testowych przed napisaniem rzeczywistej funkcji/części kodu w naszym projekcie. Łączy ona budowanie i testowanie. Gwarantuje wysoką jakość kodu oraz maksymalne pokrycie testami jak największej części aplikacji, nie tylko zapewniając jego poprawność, ale także pośrednio rozwijając architekturę danego projektu.

Zbudowana jest ona na trzech fazach: Red –> Green –> Refaktor.

Najpierw piszemy test, który nie przechodzi (Red).
Dopisujemy kod, który sprawia, że test przechodzi (Green).
Jeżeli zachodzi taka potrzeba (a zazwyczaj tak jest) – ulepszamy kod testowanej metody i testu (Refactor).
Powtarzamy cały proces (wracając do fazy Red) do momentu, aż skończymy implementować dany kawałek kodu.

Do takiego sposobu testowania aplikacji programiści używają testów jednostkowych (ang. unit test). Polega on na tym, że wydzielamy mniejszą jej część (jednostkę) i testujemy ją osobno. Może to być pojedyncza klasa, czy nawet pojedyncza metoda.

Cechy testu jednostkowego:

  • powinien testować tylko jedną funkcjonalność naszej aplikacji,
  • powinien być odizolowany od reszty naszych testów (działać niezależnie),
  • powtarzalność (stabilne wyniki przy każdym kolejnym uruchomieniu).

Struktura testu jednostkowego:
Given – przygotowanie wszystkich danych wejściowych,
When – uruchomienie testowanej metody,
Then – weryfikacja otrzymanych wyników.

*Uwaga – stosowanie powyższej struktury to nie jest wymóg techniczny, a jedynie dobra praktyka, która ma na celu poprawę czytelności naszych testów.

Testy jednostkowe można pisać bez bibliotek zewnętrznych, jednak jest to uciążliwe. W tym artykule użyłem biblioteki JUnit (istnieją też inne, np. TestNG, csUnit i NUnit, Rspec).


Przykładowy kod:

public class Calculator {

	public int sum(int a, int b) {
		return a+b;
	}
}

Wydaje się, że to bardzo prosta metoda – zwykłe dodawanie. Jednak chcąc dobrze przetestować nawet tak krótki kawałek kodu, to musimy uwzględnić wiele warunków.

Zaczniemy od poprawnego testu – tzw. happy path („szczęśliwy” przypadek). Dla naszej klasy ’Calculator’ mógłby wyglądać, np. tak:

class CalculatorTest {

    Calculator calculator = new Calculator();

    @Test
    void sumTest() {
        //given
        int a = 10;
        int b = 20;

        //when
        int result = calculator.sum(a, b);

        //then
        assertEquals(30, result);
    }
}

Następnie przejdziemy do bardziej problematycznych przypadków, czyli warunków brzegowych, np.:

– walidacja na przekazanie do metody niepoprawnych argumentów (do pierwszego argumentu, do drugiego oraz do obu jednocześnie),
– co się stanie, gdy do metody zostanie przekazana wartość pusta NULL,
– co się stanie, gdy przekażemy bardzo duże wartości, przekraczające zakres typu Integer,
– itp. itd.

Chcąc mieć pewność, że nasz kod w określonych przypadkach zachowa się tak, jak tego oczekujemy, to musimy ustalić te warunki, a następnie przygotować test z odpowiednimi asercjami.
Dzięki asercjom jesteśmy w stanie weryfikować nasze założenia wobec kodu:
– poczynając od bardzo prostych, takich jak sprawdzenie, czy metoda zwróciła konkretną wartość liczbową (jak w naszym poprawnym teście powyżej),
– do bardziej złożonych, takich jak sprawdzenie, czy zwrócony obiekt jest danego typu,
– czy ostatecznie sprawdzenie, czy metoda rzuciła oczekiwany wyjątek.


Stub, Spy oraz Mock

W tej części artykułu przyjrzymy się trzem typom obiektów używanych w testach jednostkowych. Są to Stuby, Mocki i Spy.

*Uwaga – dla lepszej przejrzystości w części kodowej pominąłem wykorzystane importy.

Stub

To obiekt zawierający przykładową implementację jakiegoś kodu, którego zachowanie imituje. Stuba używamy wtedy, gdy nie mamy dostępu do prawdziwej metody lub gdy nie chcemy angażować obiektów, zwracających prawdziwe dane, co mogłoby mieć nieprzewidziane skutki uboczne (np. modyfikacja danych w bazie danych). Warto podkreślić, że stub potrafi zwrócić zdefiniowane przez nas wartości. Nie wyrzuci też błędu, jeśli zdecydowaliśmy się nie zdefiniować danego stanu (np. metody void są puste, a wartości zwracane przez składowe są domyślne dla danego typu lub zdefiniowane [“hard-coded”]).

Poniżej znajduje się przykład, w którym testujemy metodę zwracającą listę nazwisk klientów zaczynających się na określoną literę. Na potrzeby tego przykładu utworzymy interfejs i dwie klasy. ’Service’ to interfejs z funkcją ’getCustomers()’, która zwraca listę typu String.

public interface Service {
    List<String> getCustomers();
}

Tworzymy klasę ’JavaExample’, która implementuje rzeczywisty kod, z którego zostaną zwrócone dane. Tworzymy instancję interfejsu ’Service’ w klasie, a następnie inicjalizujemy ją w konstruktorze. Aby zwrócić dane, tworzymy funkcję ’getCustomersWhoseNamesStartsWithA()’, która zwraca listę typu String. Wewnątrz metody inicjujemy nową listę ’customers’. Teraz pobieramy listę klientów za pomocą ’service.getCustomers()’ i w pętli sprawdzamy, czy nasza lista zawiera String zaczynający się na literę „A”, a jeśli tak, to dodajemy go do listy klientów. Na koniec zwracamy listę.

public class JavaExample {
    Service service;

    public JavaExample(Service service) {
        this.service = service;
    }

    public List<String> getCustomersWhoseNamesStartsWithA() {
        List<String> customers = new ArrayList<>();
        for (String customerName : service.getCustomers()) {
            if (customerName.contains("A")) {
                customers.add(customerName);
            }
        }
        return customers;
    }
}

Następnie tworzymy klasę zawierającą wszystkie przypadki testowe. W tej klasie utworzymy klasę pośredniczącą o nazwie ’StubService’, która implementuje interfejs ’Service’, a w klasie używamy metody ’getCustomers()’ do utworzenia listy z kilkoma przykładowymi imionami, którą zwrócimy. Następnie tworzymy metodę testową ’whenCallServiceIsStubbed()’. Wewnątrz metody tworzymy obiekt klasy ’JavaExample’ i przekazujemy klasę ’StubService’ jako jej argument w konstruktorze.
Testujemy wynik zwrócony przez funkcję ’getCustomersWhoseNamesStartsWithA()’ za pomocą odpowiednich asercji. W pierwszej asercji sprawdzamy rozmiar zwracanej listy, a w drugiej sprawdzamy, czy pierwszą pozycją na liście jest imię „Adam”.
Jak łatwo się domyślić – test zakończył się pomyślnie.

class JavaExampleTest {

    @Test
    public void whenCallServiceIsStubbed() {
        JavaExample service = new JavaExample(new StubService());

        assertEquals(3, service.getCustomersWhoseNamesStartsWithA().size());
        assertEquals("Adam", service.getCustomersWhoseNamesStartsWithA().get(0));
    }

    static class StubService implements Service {
        public List<String> getCustomers() {
            return Arrays.asList("Adam", "Katarzyna", "Bogdan", "Agnieszka", "Adrian", "Mateusz");
        }
    }
}

Obiekty typu stub działają dobrze dla prostych metod i przykładów, jednakże przy większej liczbie warunków testowych mogą urosnąć do sporych rozmiarów i być dość skomplikowane w utrzymaniu. Dlatego też dla większych, bardziej rozbudowanych metod lepsze są mocki.

Mock

To obiekty symulujące działanie rzeczywistych obiektów. Pozwalają określić jakich interakcji spodziewamy się w trakcie testów, a następnie zweryfikować, czy faktycznie nastąpiły. Innymi słowy – mocki dają pełną kontrolę nad zachowaniem symulowanych obiektów, oferując możliwość kompletnej weryfikacji wywołań określonych metod (czy zostały wywołane, ile razy, w jakiej kolejności, z jakimi parametrami itp.). Obiekty te są najczęściej tworzone przy użyciu biblioteki lub konkretnego frameworka (np. Mockito, JMock i EasyMock). Służą do testowania dużego zestawu testów, w których użycie stubów nie jest wystarczające. Co więcej, mogą być tworzone dynamicznie w czasie działania kodu. Obiekty typu mock to najpotężniejsza i najbardziej elastyczna wersja testów jednostkowych.

Do utworzenia mocka skorzystamy z funkcji ’mock()’, przyjmującej jako argument nazwę klasy, którą chcemy symulować. Zobaczymy jego działanie na przykładzie poniżej. Najpierw utworzymy klasę ’User’ z przykładowymi polami i konstruktorem:

public class User {
    private int id;
    private String firstName;
    private String lastName;

    public User(int id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

Następnie interfejs ’DbUsers’, zawierającą metodę ’getUserById()’, która wyciąga nazwę (imię i nazwisko) użytkownika z bazy danych:

public interface DbUsers {
    User getUserById(int userId);
}

Tworzymy również klasę ’UserService’, która implementuje nasz interfejs ’DbUsers’:

public class UserService implements DbUsers {
    private DbUsers dbUsers;

    public UserService(DbUsers dbUsers) {
        this.dbUsers = dbUsers;
    }

    @Override
    public User getUserById(int userId) {
        return dbUsers.getUserById(userId);
    }
}

Aby teraz przetestować naszą klasę ’UserService’, musimy użyć mocka na naszym interfejsie i ustawić jego zachowanie. W sekcji „given” tworzymy nasz obiekt typu mock, następnie testowy obiekt ’User’, którego użyjemy potem. Metody ’when()’ i ’thenReturn()’ zasymulowały takie działanie, w którym jeżeli na naszym mocku wywołamy metodę ’getUserById()’ z argumentem „1”, to powinien zwrócić nam obiekt ’User’. Kolejnym krokiem jest utworzenie obiektu ’UserService’ z argumentem ’dbUsers’ (czyli nasz obiekt mock). Następnie wywołujemy metodę ’getUserById()’ obiektu ’UserService’ z argumentem „1”. Na końcu weryfikujemy (stosując asercję), czy nasz wynik (result) jest równy testowemu obiektowi ’User’.

class UserServiceTest {

    @Test
    public void testGetUserById() {
        //given
        DbUsers dbUsers = mock(DbUsers.class);
        User testUser = new User(1, "Jan", "Nowak");
        
        when(dbUsers.getUserById(1)).thenReturn(testUser);

        //when
        UserService userService = new UserService(dbUsers);
        User result = userService.getUserById(1);

        //then       
        assertEquals(testUser, result);
    }
}

Spy

Są to obiekty hybrydowe – coś pomiędzy prawdziwym obiektem a obiektem typu mock (nazywany też Partial Mock – częściowy Mock). Podczas jego użycia prawdziwy obiekt pozostaje niezmieniony, a my po prostu „szpiegujemy” jego określone metody. Innymi słowy, bierzemy istniejący (rzeczywisty) obiekt i zastępujemy lub „szpiegujemy” tylko niektóre z jego metod. Obiekty typu Spy są przydatne, gdy zależy nam na korzystaniu z prawdziwego zachowania określonych metod w obiekcie lub gdy chcemy mieć możliwość weryfikacji wywołań metod zachowując ich prawdziwe zachowanie. Framework Mockito pozwala tworzyć nam obiekty typu Spy wykorzystując metodę ’spy()’. Dzięki niej możemy wywoływać normalne metody rzeczywistego obiektu. Poniższy fragment kodu pokazuje, jak używać metody ’spy()’.

Załóżmy, że mamy taką klasę:

public class Shopping {
    private int price;
    private int quantity;
 
    public Shopping() {
    }
 
    public int getQuantity() {
        return quantity;
    }
 
    public int getPrice() {
        return price;
    }
 
    int sumPrice() {
        return getPrice() * getQuantity();
    }
}

Teraz przy użyciu obiektu Spy chcielibyśmy zweryfikować metodę sumującą koszt:

class ShoppingTest {

    @Test
    void testTotalPrice() {
        //given
        Shopping shopping = spy(Shopping.class);
        given(shopping.getPrice()).willReturn(5);
        given(shopping.getQuantity()).willReturn(3);

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

        //then
        then(shopping).should().getPrice();
        then(shopping).should().getQuantity();
        assertThat(result, equalTo(15));
    }
}

Zalety/wady TDD

Spójrzmy na zalety TDD:

  • zmniejszenie liczby defektów w kodzie produkcyjnym,
  • redukcja nakładu pracy w końcowych fazach projektów,
  • nacisk na refaktoryzację prowadzi do lepszej jakości projektu w kodzie źródłowym,
  • ponieważ wynikowy kod jest solidny technicznie, TDD umożliwia szybsze wprowadzanie innowacji,
  • kod jest elastyczny i rozszerzalny (można go refaktoryzować lub przenosić przy niewielkim ryzyku jego zepsucia),
  • same testy są testowane, ponieważ programiści sprawdzają, czy każdy nowy test kończy się niepowodzeniem w ramach procesu TDD,
  • poświęcony czas i wysiłek nie zostaną zmarnowane, gdyż piszemy kod tylko dla funkcji potrzebnej do spełnienia określonych wymagań.

Przyjrzymy się teraz niektórym wadom TDD (warto zaznaczyć, że są one raczej wynikiem nieprzestrzegania najlepszych praktyk TDD niż wad jego podejścia). Ponieważ programista to też człowiek, a więc może popełniać błędy, np:

  • zapomni o częstym uruchamianiu testów,
  • pisze zbyt wiele testów naraz,
  • stworzy za bardzo rozbudowane testy,
  • ułoży testy, które są zbyt trywialne,
  • napisze testy dla trywialnego kodu.

Ale istnieją też inne zarzuty odnośnie praktyk TDD. Jednym z nich jest to, że TDD może prowadzić do lekceważenia projektów na dużą lub średnią skalę, ponieważ projektowanie oprogramowania jest za bardzo skoncentrowane na poziomie funkcji. Ponadto niektórzy twierdzą, że TDD jest ideałem, który nie nadaje się do rzeczywistych problemów w tworzeniu oprogramowania. Te problemy mogą obejmować:
– duże, złożone bazy kodu,
– kod, który musi współdziałać ze starszymi systemami,
– procesy działające pod ścisłymi ograniczeniami dotyczącymi czasu rzeczywistego, pamięci, sieci lub wydajności.


Podsumowanie

W ostatnich latach testy pojawiają się w coraz większej liczbie projektów i coraz więcej programistów/developerów wprowadza je do swojego kodu. Stosowanie praktyk TDD może tylko pomóc ulepszyć zastosowanie testów. I chociaż ich pisanie pozwala wykryć błędy w najwcześniejszej możliwej fazie (bo w trakcie pisania kodu programu), to jednak istnieją minusy takiego podejścia. Warto pamiętać, że manualne testowanie to żmudna, czasochłonna i mozolna praca. Bardzo tu łatwo o błędy. Poza tym w projektach IT wymagania niejednokrotnie się zmieniają, więc także testy muszą być często przeprowadzane. Do tego dla większości programistów nie jest naturalne pisanie testu do kodu, który jeszcze nie istnieje. Jednakże każdy porządny programista powinien testować kod, który napisze. Ponieważ trzeba mieć świadomość, że oddając kod do użytku powinniśmy być pewni, że działa jak należy.

Podobne wpisy

Dodaj komentarz

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