TDD to podejście do tworzenia oprogramowania. Na taki sposób tworzenia oprogramowania uwagę zwrócił Kent Beck. Podejście to zakłada, że przed napisaniem właściwej funkcjonalności programista zaczyna od utworzenia testu. Test ten powinien testować funkcjonalność, którą dopiero chcemy napisać.
TDD to podejście, które składa się z trzech faz. Te trzy fazy łączą się w cykl. Cały proces pisania kodu składa się z właśnie takich cykli, które powtarzasz jeden po drugim. Cykl to trzy fazy:
- Red,
- Green,
- Refactor.
Faza Red
Pierwszym krokiem jest napisanie testu. Test ten nie może się powieść, ponieważ sama funkcjonalność jeszcze nie jest zaimplementowana. Możliwe, że nawet po napisaniu takiego testu kod nie będzie się kompilował. Może się tak stać w przypadku, gdy napisałeś test dla metody, która jeszcze nie istnieje.
Sytuacja, w której testy jednostkowe nie przechodzą bardzo często w IDE oznaczana jest kolorem czerwonym.
Faza Green
Kolejnym krokiem jest napisanie kodu, który implementuje brakującą funkcjonalność. W tym momencie istotne jest to aby ten kod nie był „idealny”. Chodzi o możliwe jak najszybszą implementację, która spełnia założenia testu, który był napisany w poprzedniej fazie.
Następnie potwierdzamy to, że nasza implementacja działa jak powinna uruchamiając testy jednostkowe. Jeśli wszystko jest w porządku całość powinna zakończyć się testami jednostkowymi, które przechodzą. IDE sygnalizuje taką sytuację zielonym kolorem. Ważne jest aby w tej fazie uruchamiać wszystkie dotychczas napisane testy jednostkowe.
Faza Refactor
Refaktoryzacja (ang. refactor) to proces, w którym zmieniamy kod w taki sposób, że nie zostaje zmieniona jego funkcjonalność. Mówi się o „oczyszczaniu” kodu, doprowadzaniu go do lepszego stanu. Przykładem refaktoryzacji może być wydzielenie oddzielnej metody, która usuwa powielony kod czy stworzenie zupełnie nowej klasy odpowiedzialnej za pewną część zadań danej klasy.
Jest to ostatnia z trzech faz cyklu TDD. Faza refaktoryzacji jest bardzo istotna. Nawet doświadczeni programiści bardzo często pomijają tę fazę. Jej brak może w dłuższej perspektywie prowadzić do kodu programu, który jest trudny w utrzymaniu. Praca z takim kodem może być wówczas dużo cięższa, proste zmiany mogą zajmować bardzo dużo czasu.
Dzięki testom, które napisałeś w fazie Red czy wcześniejszych cyklach TDD, możesz czuć się swobodnie zmieniając istniejący kod. Z większą pewnością możesz zmieniać kod, po każdej zmianie uruchamiając istniejące testy jednostkowe. Takie podejście pozwala Ci bardzo szybko wychwycić potencjalne błędy, które mógłbyś wprowadzić refaktoryzując kod.
Może się zdarzyć, że faza refaktoryzacji nie zawsze jest konieczna. Usprawnianie dobrego kodu na siłę nie koniecznie może prowadzić do dobrych rezultatów.
Czym jest TDD?
To nic innego jak pisanie oprogramowania stymulowane przez testy. Cykl implementacji nowej funkcjonalności wygląda następująco:
1. Dodaj test
Dodanie każdej nowej funkcjonalności rozpoczyna się od napisania testu. Aby być w stanie napisać test musisz dobrze rozumieć implementowaną funkcjonalność — jej wymagania i wynik działania — rozbijając to na pojedyńcze cechy (tak jak w testach jednostkowych). Możesz napisać od razu wszystkie testy, lub tylko jeden, ważne by w tym etapie w ogóle nie myśleć o kodzie, który będzie im odpowiadał — masz po prostu zmapować oczekiwane zachowanie funkcjonalności.
2. Uruchom test(y)
Powinny one nie przechodzić. Każdy nowododany test nie może zakończyć się sukcesem — gdyby tak było, byłby on z bardzo dużym prawdopodobieństwem napisany w zły sposób. Pamiętaj, że taki test powinien nie przejść z znanych Ci powodów — w kolejnym kroku będziesz musiał bowiem dodać kod, który pozwoli mu zakończyć się sukcesem.
3. Napisz kod
Dodaj kod, dzięki któremu test przejdzie pozytywnie. Kod ten nie musi być perfekcyjny (na refactor będzie czas później), natomiast nie powinien on wyprzedzać napisanych testów. Dodatkowe funkcjonalności powinny być implementowane dopiero kiedy pojawią się nowe testy.
4. Uruchom testy
W rezultacie wszystkie dotychczas niedziałające testy powinny przechodzić pozytywnie (jeśli nie — wracasz do programowania). Jeśli wszystkie napisane testy kończą się sukcesem, oznacza to, że Twój kod odpowiada wymaganiom biznesowym.
5. Refactor kodu
To bardzo ważny element takiego podejścia do implementacji — jako, że TDD pozwala na chwilowe rozwiązania (o tym za chwile w przykładzie), czyszczenie kodu jest ważnym i potrzebnym etapem. Wszystkie powtórzenia powinny być wyeliminowane, a kod odpowiednio uporządkowany — jak?- o tym przeczytasz w naszym wpisie o refaktorze.
Powtórz
Aż do dostarczenia pełnej funkcjonalności. Pamiętaj, że z TDD możesz korzystać również gdy modyfikujesz już napisaną aplikację bądź pracujesz z legacy code.
Przykład
Wyobraźmy sobie, że mamy do napisania aplikację, która oblicza silnię. Poniższy przykład będzie w pseudo kodzie:
W pierwszej iteracji napisałbym test, który sprawdza, czy program poprawnie obliczył 0!
//Test
int liczba = 0;
AssertEquals(0,obliczSilnie(liczba));
Odpowiedzią na taki kod, była by metoda przyjmująca int:
obliczSilnie (int liczba){
return 1;
}
Jak widzicie, na ten moment nie robię tego w prawidłowy sposób, po prostu odpowiadam na test, tak by przechodził.
Pisząc test dla 1! okazałoby się, że nie muszę nic modyfikować w kodzie.
Dla 2! mógłbym np. napisać coś takiego:
obliczSilnie (int liczba){
if (liczba == 0)
return 0;
else
return liczba;
}
Teraz wszystkie 3 testy mi przechodzą, ale nadal nie korzystam z metody obliczania silni — powinienem to zmienić, z resztą test case dla 3! to na mnie wymusza.
Modyfikuję więc mój kod:
obliczSilnie (int liczba){
if (liczba == 1)
return 1;
else
return liczba * obliczSilnie(liczba - 1);
}
Mam więc prawie gotowy kod (brakuje tu weryfikacji czy nasz int jest liczbą naturalną), a doszliśmy do niego pisząc 4 przypadki testowe.
Kiedy stosować?
Po pierwsze, kiedy jesteś na początku swojej nauki programowania i nie do końca opanowałeś testy — wtedy takie podejście zmotywuje Cię do szybszego ich opanowania — to naprawdę super sposób na wyćwiczenie się w pisaniu testów!
Po drugie, gdy nie do końca wiesz jak zaimplementować daną funkcjonalność — TDD skupia się na dokładnym zrozumieniu wymagań i potrzeb klienta, a także zmusza Cię do rozbicia funkcjonalności na małe kroki — dzięki takiemu podejściu łatwiej będzie Ci znaleźć rozwiązanie.
Po trzecie, kiedy modyfikujesz już działające funkcjonalności takie podejście pozwoli Ci łatwiej zweryfikować koniecznie modyfikacje.
Uwaga!
TDD jako metoda programowania ma tyle samo zwolenników co przeciwników — czy po prostu osób, które nie lubią w taki sposób pracować. Nie należy z test driven development robić religii — jedynego słusznego podejścia. Warto na pewno poznać taki sposób pracy — jeśli będzie Ci on odpowiadał możesz stosować go na codzień, a jeśli nie — wiedzieć, że możesz go wykorzystać w trudnych przypadkach by lepiej zrozumieć wymagania funkcjonalności.

Stub, Spy oraz Mock, czyli biblioteka Mockito
W poniższym artykule na przeciwko siebie w ringu staną trzy obiekty pomocnicze używane w testach jednostkowych – stub, mock i spy. Dowiesz się dlaczego używamy tego typu obiektów, zobaczysz ich przykłady oraz poznasz różnice pomiędzy nimi.
Załóżmy, że współtworzymy sklep internetowy. Do korzystania z serwisu użytkownicy będą musieli utworzyć konto. Jeśli podczas jego tworzenia podadzą adres – konto stanie się aktywne. Chcielibyśmy również mieć możliwość pobierania kont klientów z bazy danych, np. w celu wysłania newslettera. Utworzymy także klasę serwisową, służącą do pobierania i filtrowania kont z repozytorium tak, by zwracać wyłącznie konta aktywne. Dysponujemy zatem klasami Address, Account, AccountService oraz interfejsem AccountRepository. Zakładamy, że nie mamy dostępu do kodu, który implementuje wspomniany interfejs:
public class Address {
private String street;
private String number;
public Address(String street, String number) {
this.street = street;
this.number = number;
}
}
public class Account {
private boolean active;
private Address defaultDeliveryAddress;
public Account(Address defaultDeliveryAddress) {
this.defaultDeliveryAddress = defaultDeliveryAddress;
if(defaultDeliveryAddress !=null) {
activate();
} else {
this.active = false;
}
}
public Account() {
this.active = false;
}
public void activate() {
this.active = true;
}
public boolean isActive() {
return this.active;
}
}
public class AccountService {
private AccountRepository accountRepository;
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
List<Account> getAllActiveAccounts() {
return accountRepository.getAllAccounts().stream()
.filter(Account::isActive)
.collect(Collectors.toList());
}
List<String> findByName(String name) {
return accountRepository.getByName(name);
}
}
public interface AccountRepository {
List<Account> getAllAccounts();
List<String> getByName(String name);
}
Rozważmy jak przetestowalibyśmy nasz kod przy użyciu tytułowych obiektów.
Stub
Stub to obiekt, który zawiera przykładową implementację imitującą działanie tej właściwej. Przykładami jego użycia mogą być następujące sytuacje:
- nie mamy dostępu do prawdziwej metody zwracającej dane
- nie chcemy angażować obiektów, które zwróciłyby prawdziwe dane, co mogłoby mieć niekorzystne skutki uboczne (np. modyfikacja danych w bazie danych)
Chcąc przetestować metodę zwracającą wszystkie aktywne konta będziemy potrzebować danych. Tak jak wspomniałem we wstępnie – nie mamy do nich dostępu. Z pomocą przychodzi właśnie klasa stubowa, która zwróci przykładowe dane:
public class AccountRepositoryStub implements AccountRepository{
@Override
public List<Account> getAllAccounts() {
Address address1 = new Address("Herberta", "33");
Account account1 = new Account(address1);
Account account2 = new Account();
Address address2 = new Address("Piłsudskiego", "12/8");
Account account3 = new Account(address2);
return Arrays.asList(account1,account2,account3);
}
@Override
public List<String> getByName(String name) {
return null;
}
}
W teście skorzystamy z powyższej klasy:
@Test
void getAllActiveAccounts() {
//given
AccountRepository accountRepositoryStub = new AccountRepositoryStub();
AccountService accountService = new AccountService(accountRepositoryStub);
//when
List<Account> activeAccounts = accountService.getAllActiveAccounts();
//then
assertThat(activeAccounts.size(), is(2));
}
Obiekty stub działają dobrze dla prostych metod i przykładów, jednakże przy większej liczbie warunków testowych oraz przy możliwym rozroście interfejsów nie sprawdzają się. Mogą urosnąć do sporych rozmiarów i być ciężkie w utrzymaniu.
Mock
Mock to obiekt symulujący działanie rzeczywistego obiektu. Pozwala określić jakich interakcji spodziewamy się w trakcie testów, a następnie zweryfikować, czy nastąpiły. Z wykorzystaniem mocka przetestujmy sytuację, w której baza danych nie zwróci żadnych danych:
@Test
void getNoActiveAccounts() {
//given
AccountRepository accountRepository = mock(AccountRepository.class);
AccountService accountService = new AccountService(accountRepository);
given(accountRepository.getAllAccounts()).willReturn(Collections.emptyList());
//when
List<Account> activeAccounts = accountService.getAllActiveAccounts();
//then
assertThat(activeAccounts.size(), is(0));
}
Do utworzenia mocka skorzystaliśmy z biblioteki Mockito 2, a konkretniej funkcji mock(), przyjmującej jako argument nazwę klasy, którą chcemy symulować. Metody given() i willReturn() zasymulowały takie działanie, w którym jeżeli na naszym mocku wywołamy metodę getAllAcounts(), to zostanie zwrócona pusta lista.
W porównaniu do stubów, mocki być tworzone dynamicznie w czasie działania kodu oraz zapewniają większą elastyczność. Dają też znacznie więcej funkcjonalności, takich jak chociażby weryfikacja wywołań metod (czy zostały wywołane, ile razy, w jakiej kolejności, z jakimi parametrami itp.).
Spy
Spy to obiekt hybrydowy – mieszanka obiektów prawdziwych oraz mocków. Jego działanie można śledzić i weryfikować. Wyróżnia go fakt, że działanie jego wybranych metod można mockować. Może on zatem być częściowo mockiem i częściowo normalnym obiektem.
Do omówienia jego działania posłużymy się nowym przykładem. Załóżmy, że posiadamy klasę Meal:
public class Meal {
private int price;
private int quantity;
public Meal() {
}
public int getQuantity() {
return quantity;
}
public int getPrice() {
return price;
}
int sumPrice() {
return getPrice() * getQuantity();
}
}
Przy użyciu obiektu spy chcielibyśmy zweryfikować metodę sumującą koszt:
@Test
void testTotalMealPrice() {
//given
Meal meal = spy(Meal.class);
given(meal.getPrice()).willReturn(15);
given(meal.getQuantity()).willReturn(3);
//when
int result = meal.sumPrice();
//then
then(meal).should().getPrice();
then(meal).should().getQuantity();
assertThat(result, equalTo(45));
}
W celu utworzeniu obiektu posłużyliśmy się wrapperem spy(). Można zauważyć, że zarówno getPrice() i getQuantity() mają zaprogramowane działanie, natomiast do zmiennej result przypisaliśmy wynik zwrócony przez prawdziwą metodę. Dodatkowo w sekcji „then” możliwe było zweryfikowanie wywołań metod, dzięki skorzystaniu z konstrukcji then().should().
Obiektów spy używamy, gdy zależy nam na korzystaniu z prawdziwego zachowania części metod w obiekcie lub gdy chcemy mieć możliwość weryfikacji wywołań metod zachowując ich prawdziwe zachowanie.
Podsumowanie
Po przeczytaniu tego artykułu wiesz czym jest stub, mock i spy, potrafisz wskazać różnice między poszczególnymi obiektami oraz w jakich okolicznościach je stosować. Korzystanie z tego typu rozwiązań znacznie ułatwi tworzenie testów jednostkowych, a także zapobiegnie niechcianym skutkom ubocznym, np. utracie lub modyfikacji danych w bazach.
