Czym jest TDD?
Test-driven development (TDD) to sposób produkcji oprogramowania, którego główną zasadą jest pisanie testów przed napisaniem właściwego kodu. W skrócie proces tworzenia możemy zawrzeć w trzech krokach:
- Napisanie testu
- Zaprogramowanie funkcji, która przechodzi test
- Refactoring kodu
Podejście takie zapewnia nam przede wszystkim maksymalne pokrycie kodu testami. Co warto podkreślić, maksymalne, nie oznacza 100%, ponieważ nie wszystkie metody warto testować (jak np. proste gettery czy setter), nastawienie na sprawdzanie każdej linijki kodu jest błędne.
Red – green – refactor

Powyższy diagram przedstawia cykl pracy przy wykorzystaniu metodyki TDD. Pisanie zaczynamy od testu jednostkowego, który „nie przechodzi”. Jest on reprezentacja tego jak funkcjonalność ma działać w przyszłości, zbiorem oczekiwanego zachowania. Kolejnym krokiem jest napisanie kodu, który uprzednio napisany test przechodzi. Nie należy tutaj kłaść nacisku na optymalne rozwiązanie problemu, a na jego rozwiązanie w ogóle (zachowując zdrowy rozsądek oczywiście), ponieważ tym zajmiemy się w kolejnym etapie, którym jest refactoring. Wspomniany wcześniej refactoring występuje na końcu cyklu, dopiero tutaj musimy przysiąść nad kodem i opracować go tak, aby spełniał określone standardy.
Istotną kwestią jest, aby wyciągnąć ze wspomnianego diagramu informację, iż każdy krok jest równie ważny i nie wolno lekceważyć żadnego z nich, przy czym należy pamiętać o kolejności red – green – refactor. Kolejną niemniej istotną sprawą jest pisanie kodu w możliwie małych sekcjach. Zaczynamy od pojedynczych testów, następnie piszemy tylko taką ilość kodu, który przychodzi ten test i proces powtarzamy.
TDD na przykładzie
Na poniższym, bardzo prostym przykładzie, zostaną omówione podstawowe założenia TDD. Załóżmy, że mamy klasę Unit, przedstawiającą jednostkę w grze, z trzema polami hitPoints, attack oraz speed. Chcemy utworzyć dla niej klasę nadrzędną UnitRepository, która będzie zarządzała jednostkami posiadanymi przez danego gracza. Stworzymy ją wykorzystując podejście TDD.
public class Unit {
private int hitPoints;
private int attack;
private int speed;
public Unit(int hitPoints, int attack, int speed) {
this.hitPoints = hitPoints;
this.attack = attack;
this.speed = speed;
}
public int getHitPoints() {
return hitPoints;
}
public int getAttack() {
return attack;
}
public int getSpeed() {
return speed;
}
}Zgodnie z TDD rozpoczynamy od stworzenia pierwszego testu. Na początku chcielibyśmy mieć możliwość dodawania jednostek do repozytorium. Piszemy więc test.
class UnitsRepositoryTest {
@Test
void shouldBeAbleToAddUnitsToRepository(){
//given
UnitRepository unitRepository = new UnitRepository();
}
}I tak naprawdę już w tym momencie otrzymujemy test nieprzechodzący (red), ponieważ nie stworzyliśmy jeszcze klasy UnitRepository. Zabieramy się więc za jej stworzenie i uruchamiamy test. Tutaj trzeba zaznaczyć, że powinniśmy się kierować zasadą, która mówi, że piszemy tylko wystarczającą ilość kodu do zaliczenia testu, co w naszym przypadku oznacza stworzenie pustej klasy UnitRepository.
public class UnitRepository {
}Uruchamiamy test i jak się okazuje, zostaje on zaliczony (green). W tym momencie cykl się zakończył, więc przechodzimy do nowej iteracji. Jako, że metoda ma docelowo dodawać jednostkę do repozytorium, tworzymy kolejny test:
class UnitsRepositoryTest {
@Test
void shouldBeAbleToAddUnitsToRepository(){
//given
UnitRepository unitRepository = new UnitRepository();
Unit unit = new Unit(5,8,20);
//when
UnitRepository.addUnitToRepository(unit);
}
}Test taki już nie przechodzi, ponieważ nie mamy zaimplementowanej metody addUnitToRepository. Zajmijmy się jej implementacją. Finalnie, po kilku kolejnych iteracjach, klasy jakie otrzymaliśmy będą wyglądały następująco:
class UnitsRepositoryTest {
@Test
void shouldBeAbleToAddUnitsToRepository(){
//given
UnitRepository unitRepository = new UnitRepository();
Unit unit = new Unit(5,8,20);
//when
unitRepository.addUnitToRepository(unit);
//then
assertThat(unitRepository.getAllUnits().get(0), is(unit));
}
}
public class UnitRepository {
private final List<Unit> unitsList = new ArrayList<>();
public void addUnitToRepository(Unit unit) {
unitsList.add(unit);
}
public List<Unit> getAllUnits(){
return unitsList;
}
}Warto mieć na uwadze, że w tak prostym przykładzie, nie zachodzi potrzeba rafactoringu kodu, jednak przy bardziej skomplikowanych funkcjonalnościach, zawsze po etapie green należy kod ustandaryzować według stosowanych w zespole reguł. Pamiętajmy również, że refactoring tyczy się również kodu testowego o który należy dbać tak samo jak o logikę biznesową.
Czy to się opłaca?
Na pierwszy rzut oka może się wydawać, że podejście TDD jest bardzo czasochłonne, wiąże się z pisaniem wielu testów, które niejednokrotnie są dłuższe niż kod samej logiki biznesowej. Dochodzi dodatkowo obsługa i utrzymanie dodatkowych funkcji testujących. Jednak te niedogodności okazują się dużo mniej istotnie jeśli przyrównać je do korzyści jakie wynikają z TDD. Przy kierowaniu się tą metodyką mamy zawsze zapewnione dobre pokrycie testami, co daje kolejne profity jak łatwe modyfikacje, rozszerzalność czy stabilność działania aplikacji. Programista, który pracuje z tak napisanym kodem nie boi się dokonywać zmian, ulepszać, bo od razu ma informację zwrotną czy jego pomysły i rozwiązania działają tak jak to zaplanował.
Iteracyjność TDD daje również na pozór niewidoczną zaletę. Praca podzielona jest na krótkie cykle „nie działa – działa”, co daje programiście większą satysfakcję z uwagi na małe, aczkolwiek często odnoszone sukcesy.
Podsumowanie
Jak każde rozwiązanie, TDD ma wady i zalety. Decyzja o zastosowaniu tej metodyki powinna zostać podjęta świadomie i zależeć od rodzaju i rozmiaru projektu. Przy większych, koszt ten zwraca się z nawiązką w późniejszych etapach. Szczerze polecam zainteresować się tematyką TDD i mam nadzieję, że udało mi się na tyle dobrze zaprezentować to podejście, aby zachęcić Cię do spróbowania swoich sił i wdrożenia tej metodologii w swoim projekcie!