TESTY JEDNOSTKOWE – TDD
TDD (Test Driven Development) – jest podejściem do rozwoju oprogramowania, w którym najpierw pisany jest test, następnie kod produkcyjny przechodzący ten test. Cykl zamyka refaktoryzacja. TDD jest dyscypliną, co oznacza, że nie jest czymś co przychodzi naturalnie, ponieważ korzyści nie są natychmiastowe, ale pojawiają się dopiero w dłuższej perspektywie kiedy wypracujemy dobry schemat tego podejścia w praktyce pisania oprogramowania.
Tradycyjny TDD skupia się na rozpoczęciu prac od najmniejszego zakresu testów spełniającego zadane wymagania.
Najmniejszy cykl TDD zawiera trzy kroki:
– Napisanie testu jednostkowego, który się nie powiedzie (Red).
– Napisanie najprostszego możliwego kodu, który umożliwi przejście testu (Green).
– Refaktoryzacja w celu uzyskania lepszego kodu (Jest procesem przebudowy istniejącego kodu bez zmiany jego zachowania zewnętrznego – Refactoring).
W kontekście TDD istnieją dwie zasady: FIRST oraz CORRECT które zachęcają abyśmy kierowali się następującymi krokami podczas pisania testów:
1. Fast — szybkie.
2. Independent — niezależne od innych testów.
3. Repeatable — powtarzalne (uruchomione wiele razy zawsze zwrócą te same rezultaty).
4. Self-checking — stwierdzające czy test przeszedł lub nie (najlepiej w pełni automatyczne np. po dodaniu kolejnej gałęzi uruchomione zostają testy).
5. Timely — pisane zawsze w czasie tworzenia czy zmiany danej funkcjonalności.
1. Conformance – zgodność zarówno z wymaganiami biznesowymi jak i zgodność formatu danych (np. oczekiwany format danych w przypadku numeru Pesel).
2. Ordering – porządek danych wejściowych jak również wyjściowych (np. testowanie dla przypadków list według kolejności wejścia/wyjścia).
3. Range – kontrola zakresu wartości dla danego typu zmiennych (np. dla maksymalnej wartości integer) oraz użycie rozsądnego zakresu wartości dla pewnych zmiennych (jak np. wiek osoby nie przekracza 200 lat).
4. Reference – sprawdzenie czy warunki wstępne dla działania danej funkcjonalności zostały spełnione (metodologia: given/ when/ then).
5. Existence – kontrola zachowania metod kiedy jako parametr prześlemy wartość null lub zero, oraz kiedy wartości nie powinne jeszcze być ustawione.
6. Cardinality – monitorowanie zawartości elementów zbioru po wykonaniu konkretnej operacji na tym zbiorze (np. usuwanie danych).
7. Time – sprawdzenie kolejności wywołania metod oraz zwrócenie szczególnej uwagi na wielowątkowość w kodzie aplikacji.
W nawiązaniu do schematu graficznego podejścia TDD, widzimy, że powinniśmy starać się przeznaczyć taki sam czas na poszczególne etapy danego cyklu programowania
(RED/GREEN/REFACTORING). Dzięki temu będziemy wypracowywali lepszy schemat pracy nad projektem, co w dłuższej perspektywie czasu będzie przekładać się pozytywnie
na jakość pisanego przez nas kodu oraz niezawodność naszych aplikacji.
W poniższym przykładzie postaram się zobrazować jak może wyglądać cykl pracy z TDD:
Stwórzmy klasę testową o nazwie ObliczenNaStringuTest.
public class ObliczeniaNaStringuTest {
}
Napiszmy pierwszą metodę testową której zadaniem będzie wyrzucanie wyjątku w przypadku kiedy String z danymi będzie zawierał więcej niż dwie liczby.
Będziemy potrzebowali zatem utworzyć rzeczywistą klasę do dalszej pracy na której będziemy przeprowadzać testy – np. ObliczeniaNaStringu
W klasie tej napiszemy docelowo metodę która zajmie się dodawaniem liczb przesłanych w Stringu.
public class ObliczeniaNaStringu {
public void dodajLiczby(String numery) {
}
}
Teraz napiszmy pierwszą metodę testową do wyrzucenia wyjątku kiedy String będzie zawierał więcej niz dwie liczby.
public class ObliczeniaNaStringuTest {
@Test
public void wyrzucWyjatekKiedyStringZawieraWiecejNizDwieLiczby() {
ObliczeniaNaStringu obliczeniaNaStringu = new ObliczeniaNaStringu();
assertThrows(IllegalStateException.class, () -> obliczeniaNaStringu.dodajLiczby("1,2,3"));
}
}
Po uruchomieniu, tak jak się spodziewamy, test nie przechodzi gdyż metoda dodajLiczby() jest pusta i nic jeszcze się nie dzieje.
Wykonaliśmy więc pierwszą część cyklu TDD jako że utworzylismy metodę testową która nie przechodzi testu (część cyklu RED).
W kolejnym etapie zajmiemy się rozszerzeniem metody dodajLiczby() ale tylko do momentu w którym napisany przez nas test będzie juz działał.
Dodajmy zatem trochę logiki do metody dodajLiczby():
public class ObliczeniaNaStringu {
public void dodajLiczby(String numery) {
String[] listaLiczb = numery.split(",");
if (listaLiczb.length > 2) {
throw new IllegalStateException("Dozwolone są tylko dwie liczby oddzielone przecinkiem (,) ");
}
}
}
Po uruchomieniu otrzymujemy poprawne przejście testu i wszystko wygląda dobrze z punktu widzenia wymagań naszego testu.
Wykonaliśmy więc drugą część cyklu TDD jako że zmodyfikowaliśmy metodę dodajLiczby() tak, aby umożliwiła ona pozytywne przejście testu (część cyklu GREEN).
W kolejnym etapie cyklu powinniśmy zająć się refaktoryzacją naszego kodu. Na tą chwilę mamy minimalną ilość kodu więc krok ten możemy pominąć jednak generalnie
jest ważnym i niezbędnym aby zaraz po każdym otrzymaniu pozytywnego wyniku testu zająć się refaktoryzacją tego co napisaliśmy w tym cyklu (część cyklu REFACTORING).
Kiedy ukończyliśmy refaktoryzację, możemy uznać jeden cykl TDD za zakończony, mimo że metoda dodajLiczby() nie jest jeszcze ukończona jeśli chodzi o doclową
funkcjonalność dodawania, to udało nam się spełnić wymagania jeśli chodzi o konkretny test: „wyrzucWyjatekKiedyStringZawieraWiecejNizDwieLiczby()”.
Każdy następny cykl TDD wyglądać będzie analogicznie.
Dodajmy więc kolejny test o nazwie wyrzucWyjatekKiedyStringNieSkladaSieTylkoZLiczb() który będzie wyrzucał wyjątek w sytuacji kiedy przynajmniej jeden element przesłany
w Stringu nie będzie zawierał liczby:
public class ObliczeniaNaStringuTest {
@Test
public void wyrzucWyjatekKiedyStringZawieraWiecejNizDwieLiczby() {
ObliczeniaNaStringu obliczeniaNaStringu = new ObliczeniaNaStringu();
assertThrows(IllegalStateException.class, () -> obliczeniaNaStringu.dodajLiczby("1,2,3"));
}
@Test
public void wyrzucWyjatekKiedyStringNieSkladaSieTylkoZLiczb() {
ObliczeniaNaStringu obliczeniaNaStringu = new ObliczeniaNaStringu();
assertThrows(NumberFormatException.class, () -> obliczeniaNaStringu.dodajLiczby("4,a"));
}
}
Następnie zmodyfikujmy metodę dodajLiczby() tak, aby nowo napisany test przechodził:
public class ObliczeniaNaStringu {
public void dodajLiczby(String numery) {
String[] listaLiczb = numery.split(",");
if (listaLiczb.length > 2) {
throw new IllegalStateException("Dozwolone są tylko dwie liczby oddzielone przecinkiem(,) ");
} else {
for (String numer : listaLiczb) {
Integer.parseInt(numer); // Jeśli to nie jest numer, to metoda parseInt() wyrzuci bląd NumberFormatException
}
}
}
}
Teraz zróbmy refaktoryzację. Widzimy że w nowej metodzie testowej tworzymy obiekt obliczeniaNaStringu podobnie jak robiliśmy to w pierwszej metodzie testowej.
Możemy zatem na tą chwilę stworzyć prywatny obiekt klasy ObliczeniaNaStringu który będzie mógł być wykorzystany przez wszystkie metody testowe w klasie ObliczeniaNaStringuTest. Dzięki użyciu adnotacji @BeforeEach możemy w odrębnej funkcji (np. inicjalizacjaObiektuKlasyObliczeniaNaStringu()) umieścić samo utworzenie tego obiektu, który będzie dostępny przed uruchomieniem każdej metody testowej w klasie ObliczeniaNaStringuTest:
private ObliczeniaNaStringu obliczeniaNaStringu;
@BeforeEach
void inicjalizacjaObiektuKlasyObliczeniaNaStringu()(){
obliczeniaNaStringu = new ObliczeniaNaStringu();
}
Dzięki temu podejściu możemy usunąć linię kodu dotyczącą tworzenia obiektu klasy ObliczeniaNaStringu z naszych wcześniejszych dwóch testów.
Zobaczmy jak będzie to wyglądało po zmianie:
public class ObliczeniaNaStringuTest {
private ObliczeniaNaStringu obliczeniaNaStringu;
@BeforeEach
void inicjalizacjaObiektuKlasyObliczeniaNaStringu(){
obliczeniaNaStringu = new ObliczeniaNaStringu();
}
@Test
public void wyrzucWyjatekKiedyStringZawieraWiecejNizDwieLiczby() {
assertThrows(IllegalStateException.class, () -> obliczeniaNaStringu.dodajLiczby("1,2,3"));
}
@Test
public void wyrzucWyjatekKiedyStringNieSkladaSieTylkoZLiczb() {
assertThrows(NumberFormatException.class, () -> obliczeniaNaStringu.dodajLiczby("4,abc"));
}
}
W ten sposób ukończyliśmy kolejny cykl TDD.
Dopiszmy kolejny test w którym zweryfikujemy sytuację kiedy String będzie pusty:
@Test
public void zwrocZeroKiedyStringJestPusty() {
assertEquals(0, obliczeniaNaStringu.dodajLiczby(""));
}
Następnie zmodyfikujemy metodę dodajLiczby() aby test przechodził.
Jako, że do tej pory metoda dodajLiczby() była typu void, będziemy musieli dokonać zmiany aby zwracała typ integer:
public int dodajLiczby(String numery) {
String[] listaLiczb = numery.split(",");
if (listaLiczb.length > 2) {
throw new IllegalStateException();
} else {
for (String numer : listaLiczb) {
if(!numer.isEmpty()){
Integer.parseInt(numer);
}
}
}
return 0;
}
Test w tym momencie przechodzi więc kolejny etap cyklu został wykonany.
Teraz zróbmy refaktoryzację.
Aby funkcja dodajLiczby() zawsze zwracała jakąś określoną wartość int, dodajmy do niej zmienną np. wynik oraz zwróćmy na koniec jej wartość.
Jeśli wszystko pójdzie jak należy zwrócimy wynik dodawania, jeśli natomiast coś pójdzie nie tak, zostanie zwrócona wartość 0.
public int dodajLiczby(String numery) {
int wynik = 0;
String[] listaLiczb = numery.split(",");
if (listaLiczb.length > 2) {
throw new IllegalStateException();
} else {
for (String numer : listaLiczb) {
if(!numer.isEmpty()){
wynik += Integer.parseInt(numer);
}
}
}
return wynik;
}
Uzupełniliśmy w ten sposób kolejny cykl TDD.
Na koniec dodajmy zatem test który będzie weryfikował czy funkcja dodajLiczby() zwraca oczekiwany wynik dodawania po przesłaniu dwóch poprawnych liczb:
@Test
public void zwrocSumeLiczbKiedyPrzeslaneSaDwieLiczby() {
assertEquals(3+6, obliczeniaNaStringu.dodajLiczby("3,6"));
}
Podsumowanie
Widzimy, że podejście TDD zmusza nas do innego sposobu myślenia w trakcie programowania. Kiedy zaczynamy pracować nad nową funkcjonalnoscią od strony napisania testu, spoglądamy na problem trochę bardziej z punktu widzenia „co może pójść nie tak ?” i rozważamy szereg przypadków testowych które zapewnią że nowa funkcjonalność będzie działała poprawnie i stabilnie w różnych okolicznościach.
Mam nadzieję, że ten artykuł pomaga zrozumieć czym jest Test Driven Development i zachęci Cię do spróbowania tego podejścia podczas programowania.