Testy jednostkowe #3 – Test Driven Development

Test Driven Development

Zazwyczaj proces pisania programów wygląda następująco: najpierw wprowadzamy nową funkcjonalność, a następnie piszemy testy jednostkowe. Takie podejście wydaje się oczywiste i naturalne. Jednak istnieje również inne podejście, odwracające kolejność postępowania – jest nim właśnie Test Driven Development.

Wprowadzenie

Test Driven Development jest metodą pisania oprogramowania, w której najpierw piszemy test dla funkcjonalności, którą chcemy przetestować, a następnie wprowadzamy tą funkcjonalność. Tworzenie oprogramowania w TDD polega na wielokrotnym powtarzaniu niewielkich kroków (iteracji) składających się z trzech faz:
  • Red – piszemy test dla funkcjonalności, którą chcemy wprowadzić. Test powinien się zaświecić na czerwono lub też kod może się wogóle nie skompilować. Dzieje się tak, gdyż często w teście wywołujemy metodę, która nie istnieje i/lub odwołujemy się do klasy, która nie istnieje. Na końcu uruchamiany wszystkie testy, aby upewnić się, że nasz nowy test faktycznie nie przechodzi. Następnie przechodzimy do drugiej fazy.
  • Green – wprowadzamy funkcjonalność, dla której napisaliśmy test w fazie Red. Piszemy minimalną ilość kodu niezbędną to tego, aby test zaświecił się na zielono. Kod ten nie musi być idealny, może w nim występować redundancja itd. Na tym etapie naszym celem jest jedynie sprawienie, aby test przeszedł. Na końcu tej fazy także uruchamiamy wszystkie testy.
  • Refactor – analizujemy napisany przez nas kod (zarówno produkcyjny jak i testowy) i dokonujemy jego refaktoryzacji. Możemy np. usunąć powtarzające się linie kodu, usunąć rzeczy niepotrzebne, oddelegować część funkcjonalności do osobnych metod lub klas, zmienić nazwy zmiennych, obiektów, metod, klas na bardziej oddające ich przeznaczenie itp. Tutaj usprawniamy wszystko to,co napisaliśmy, aby z powodzeniem zakończyć dwie poprzednie fazy. Tą fazę, podobnie jak dwie poprzednie, kończymy uruchomieniem wszystkich testów.
Po zakończeniu takiego cyklu przechodzimy do następnej iteracji i zaczynamy wszystko od początku.
Faza refaktoryzacji nie zawsze jest konieczna. Czasem wprowadzamy prostą, nieskomplikowaną funkcjonalność, której nie ma sensu usprawniać na siłę. Jednak nie należy uważać, iż faza Refactor jest rzeczą opcjonalną. Jest ona nieodłączną częścią TDD i jej ignorowanie może prowadzić do powstania nieczytelnego oprogramowania, które potem trudno utrzymać.
Ważne jest również to, aby po każdej fazie uruchomić wszystkie testy, a nie tylko test aktualnie wprowadzanej funkcjonalności. Dzięki temu widzimy czy zmiany w kodzie nie powodują zaświecenia na czerwono pozostałych testów.

Przykład

Jako przykład programowania w oparciu o TDD niech będzie dodanie nowej funkcjonalności do klasy CallService z poprzedniego artykułu. Dla przypomnienia, klasa CallService zawiera metodę GetAllCalls(), która pobiera obiekty typu Call (połączenia telefoniczne) z zewnętrznej bazy danych. Drugą z metod tej klasy to getCallsByRecipient(), która na podstawie danych pobranych przy użyciu pierwszej metody, zwraca tylko te obiekty klasy Call, które posiadają konkretny obiekt klasy Recipient (adresat). Pierwszą metodę mockowaliśmy, a drugą wywoływaliśmy w jej rzeczywistej postaci. Na tą chwilę kod produkcyjny i testowy wygląda następująco:
public class Call {

    private Recipient recipient;
    private int callTime;

    public Call(Recipient recipient, int callTime) {
        this.recipient = recipient;
        this.callTime = callTime;
    }

    //getters & setters
}


public class CallService {

    public List<Call> getAllCalls() {

        List<Call> calls = new ArrayList<>();
        //code to get all calls from database
        return calls;
    }

    public List<Call> getCallsByRecipient(Recipient recipient) {

        return getAllCalls().stream()
                .filter(call -> call.getRecipient().equals(recipient))
                .collect(Collectors.toList());
    }

    public int getTotalCallTime(Recipient recipient) {
        return getCallsByRecipient(recipient).stream()
                .map(Call::getCallTime)
                .reduce(Integer::sum)
                .get();
    }
}


class CallServiceTest {

    @Test
    void shouldGetCallsByRecipient() {

        //given
        Recipient recipient1 = new Recipient("Jan", "Nowak", 22, "Wrocław", "544-661-112");
        Recipient recipient2 = new Recipient("Adam", "Kowalski", 35, "Chorzów", "223-717-009");
        CallService callServiceSpy = Mockito.spy(CallService.class);

        given(callServiceSpy.getAllCalls()).willReturn(Arrays.asList(
                new Call(recipient1, 97),
                new Call(recipient2, 121),
                new Call(recipient2, 35),
                new Call(recipient1, 411)
        ));

        //when
        List<Call> calls = callServiceSpy.getCallsByRecipient(recipient1);

        //then
        assertThat(calls.size(), equalTo(2));
        assertThat(calls.get(0).getRecipient(), sameInstance(calls.get(1).getRecipient()));
    }
}  
Następną funkcjonalnością, jaką chcemy wprowadzić, to metoda zwracająca łączny czas wszystkich połączeń telefonicznych dla jednego adresata. Zrobimy to przy użyciu Test Driven Development. Do dzieła zatem!

Iteracja 1

Faza Red – napisanie nie przechodzącego testu dla nowej funkcjonalności.
W tym teście stworzymy kilka obiektów klasy Call. Następnie „zamockujemy” metodę GetAllCalls(), która będzie zwracać te obiekty. Następnie wywołamy metodę, która zwraca łączny czas połączeń dla jednego adresata (metoda ta jeszcze nie istnieje).
@Test
void shouldReturnTotalCallTimeByRecipientSpecified() {

	//given
	Recipient recipient1 = new Recipient("Jan", "Nowak", 22, "Wrocław", "544-661-112");
	Recipient recipient2 = new Recipient("Adam", "Kowalski", 35, "Chorzów", "223-717-009");
	CallService callServiceSpy = Mockito.spy(CallService.class);

	given(callServiceSpy.getAllCalls()).willReturn(Arrays.asList(
			new Call(recipient1, 97),
			new Call(recipient2, 121),
			new Call(recipient2, 35),
			new Call(recipient1, 411)
	));

	//when
	int totalCallTime = callServiceSpy.getTotalCallTime(recipient1);
} 
Na tym etapie kończymy fazę Red. Wprawdzie nasz test nie został jeszcze zakończony (w końcu nie wykonujemy żadnej asercji), ale dotarliśmy do momentu, który chcieliśmy osiągnąć – napisaliśmy test który nie przechodzi. W tym przypadku nawet się nie kompiluje – wywołujemy metodę getTotalCallTime(), która nie istnieje.
Faza Green – napisanie minimalnej ilości kodu, aby test zaświecił się na zielono.
Na tym etapie przechodzimy do klasy CallService i tworzymy nową metodę, którą wywołujemy w metodzie testowej:
public int getTotalCallTime(Recipient recipient) {
	return 1;
} 
Na tym kończymy fazę Green – napisaliśmy minimalną ilość kodu, aby test przeszedł. Wystarczyło utworzyć naszą metodę i zwrócić jakikolwiek wynik, aby program się skompilował. Właśnie tak wygląda pisanie kodu metodą TDD – piszemy minimalną ilość kodu i posuwamy się małymi kroczkami do przodu.
Faza Refactor – refaktoryzacja napisanego dotychczas kodu.
Na chwilę obecną napisaliśmy na tyle mało kodu, że refaktoryzacja nie jest potrzebna. Na tym kończymy fazę Refactor a zarazem pierwszą iterację. Przechodzimy do iteracji drugiej.

Iteracja 2

Faza Red
@Test
void shouldReturnTotalCallTimeByRecipientSpecified() {

	//given
	Recipient recipient1 = new Recipient("Jan", "Nowak", 22, "Wrocław", "544-661-112");
	Recipient recipient2 = new Recipient("Adam", "Kowalski", 35, "Chorzów", "223-717-009");
	CallService callServiceSpy = Mockito.spy(CallService.class);

	given(callServiceSpy.getAllCalls()).willReturn(Arrays.asList(
			new Call(recipient1, 97),
			new Call(recipient2, 121),
			new Call(recipient2, 35),
			new Call(recipient1, 411)
	));

	//when
	int totalCallTime = callServiceSpy.getTotalCallTime(recipient1);

	//then
	assertThat(totalCallTime, equalTo(508));
} 
Dodajemy do naszego testu asercję. Skoro metoda przyjmuje jako argument obiekt recipient1, a referencję do tego obiektu posiada pierwszy i czwarty obiekt klasy Call, to łączny czas rozmów telefonicznych powinien wynosić 97+11=508. Jednak nasza metoda na chwilę obecną zwraca 1, więc test nie przechodzi. To powoduje zakończenie fazy Red.
Faza Green
public int getTotalCallTime(Recipient recipient) {
	return getAllCalls().stream()
			.filter(call -> call.getRecipient().equals(recipient))
			.map(Call::getCallTime)
			.reduce(Integer::sum)
			.get();
} 
W tej fazie dokonujemy właściwej implementacji naszej metody. Najpierw pobieramy wszystkie obiekty Call dzięki getAllCalls(). Następnie filtrujemy wyniki po konkretnym adresacie. Na końcu zwracamy sumę wszystkich połączeń z pozostałych obiektów Call. To powoduje zaświecenie testu na zielono i zakończenie fazy Green.
Faza Refactor
W fazie refaktoryzacji możemy poprawić kilka rzeczy. Na początku poprawmy naszą metodę produkcyjną getTotalCallTime(). Zauważmy, że w tej metodzie najpierw wywołujemy getAllCalls(), a następnie filtrujemy po adresacie. Taki sam kod mamy już napisany w klasie getCallsByRecipient(). Aby pozbyć się redundancji zmieńmy nieco kod klasy getTotalCallTime() poprzez dodanie wywołania metody getCallsByRecipient().
public int getTotalCallTime(Recipient recipient) {
	return getCallsByRecipient(recipient).stream()
			.map(Call::getCallTime)
			.reduce(Integer::sum)
			.get();
} 
Kolejna rzecz, którą możemy usprawnić, znajduje się w klasie testowej. Zauważmy, że w obydwóch metodach testowych tworzymy takie same obiekty klasy Recipient, takie same listy obiektów typu Call oraz takie same obiekty typu Spy. Pozbądźmy się tych niepotrzebnych powtórzeń.
@ExtendWith(MockitoExtension.class)
class CallServiceTest {

    Recipient recipient1 = new Recipient("Jan", "Nowak", 22, "Wrocław", "544-661-112");
    Recipient recipient2 = new Recipient("Adam", "Kowalski", 35, "Chorzów", "223-717-009");

    List<Call> callList = Arrays.asList(
            new Call(recipient1, 97),
            new Call(recipient2, 121),
            new Call(recipient2, 35),
            new Call(recipient1, 411)
    );

    @Spy
    CallService callServiceSpy;

    @Test
    void shouldGetCallsByRecipient() {

        //given
        given(callServiceSpy.getAllCalls()).willReturn(callList);

        //when
        List<Call> calls = callServiceSpy.getCallsByRecipient(recipient1);

        //then
        assertThat(calls.size(), equalTo(2));
        assertThat(calls.get(0).getRecipient(), sameInstance(calls.get(1).getRecipient()));
    }

    @Test
    void shouldReturnTotalCallTimeByRecipientSpecified() {

        //given
        given(callServiceSpy.getAllCalls()).willReturn(callList);

        //when
        int totalCallTime = callServiceSpy.getTotalCallTime(recipient1);

        //then
        assertThat(totalCallTime, equalTo(508));
    }
} 
Jak widać, wyprowadziliśmy poza metody testowe inicjalizację wszystkich zmiennych, listy oraz utworzenie Spy. Dzięki temu nie musimy się powtarzać i wykonywać tego za każdym razem we wszystkich testach.
Na tym zakończyliśmy drugą iterację i zarazem zakończyliśmy wprowadzenie nowej funkcjonalności z uprzednim jej przetestowaniem. Tak właśnie wygląda pisanie kodu korzystając z metody Test Driven Developmnent.

Zalety stosowania TDD

  • Test Driven Development wymusza na programiście pisanie testów do każdej nowo wprowadzanej funkcjonalności. Powoduje to, że sam kod produkcyjny jest w mniejszym stopniu narażony na błędy. Programiści rzadziej stają przed koniecznością debugowania programu.
  • Konieczność napisania testu do niewprowadzonej jeszcze funkcjonalności powoduje, że programista musi dokładnie rozumieć tą funkcjonalność oraz jakie wymagania powinna spełniać. Dzięki temu jej późniejsza implementacja jest łatwiejsza.
  • Dzięki mniejszej ilości popełnianych błędów oraz łatwiejszej identyfikacji tych, które jednak się pojawią, całkowity czas od rozpoczęcia do ukończenia projektu jest krótszy niż w przypadku stosowania innych metod testowych.
  • Pisanie kodu poprzez posuwanie się do przodu krótkimi iteracjami powoduje, iż mamy większą ilość mniejszych klas i metod, a tym samym łatwiej jest o zachowanie zasady pojedynczej odpowiedzialności. Dzięki temu kod jest łatwiejszy do zrozumienia.

Podsumowanie

Test Driven Development na pierwszy rzut oka może wydawać się metodą nieintuicyjną, która wymusza napisanie większej ilości kodu niż stosując inne metody testowe. Prawdą jest, iż aby móc stosować TDD w praktyce, należy odpowiednio przygotować zespoły developerskie. Początkowo stosowanie tej metody faktycznie wydłuża czas programowania. Potrzeba też nieco dyscypliny i samozaparcia, aby stosować się do jej zasad. Jednak jej niezaprzeczalne zalety powodują, iż stosowanie Test Driven Development naprawdę się opłaca i daje realne korzyści.

Znajdź mnie również na:

arrow