Test Driven-Development
Co to jest?
Jest to technika pisania oprogramowania, w której główną ideą jest pisanie testu unitowego do funkcjonalności, która jeszcze nie istnieje, a dopiero potem napisanie kodu implementującego tą funkcjonalność.
Dlaczego testujemy kod?
- Dzięki testom kod staje się łatwiejszy do zrozumienia
- Łatwiej jest wprowadzać zmiany w kodzie
- Można włączać testy automatyczne
- Sprawia, że odnajdujemy błędy w momencie pisania danej funkcjonalności. Odnajdywanie błędów później jest bardzo trudne, a im później, tym jest gorzej
Reguły
- Faza RED – „Nie możesz napisać żadnego produkcyjnego kodu, dopóki nie będziesz miał nie przechodzącego testu.”
- Najpierw trzeba wymyślić jaką chcemy dodać funkcjonalność
- Piszemy tylko małe testy w plikach testowych i badamy mały kawałek funkcjonalności
- Upewniamy się, że test nie przechodzi
- Faza GREEN – „Nie możesz napisaćwięcej testu, jeżeli ten test nie przechodzi. Błąd kompilacji jest nie działającym testem.”
- Piszemy tylko w kodzie produkcyjnym
- Naszym celem jest, żeby wszystkie testy przechodziły i były zielone
- Kod ma być funkcjonalny, a nie „piękny”
- Testy puszczamy tak szybko jak to tylko możliwe
- Faza REFACTOR – „Nie możesz napisać więcej kodu produkcyjnego niż ten wymagany do przejścia obecnego testu.”
- Refaktorujemy kod
- Nie dodajemy nowych funkcjonalności
- Nie możemy tym popsuć testów, muszą przechodzić
Inaczej można to przezać za pomocą schematu Red Green Refactor
Wnioski z schematu?
- Należy poświęcić tyle samo czasu na każdy z tych etapów
- Zawsze powinniśmy zrobić refaktor, nie można go pomijać
- Oddzielamy rozwiązywanie problemu od tworzenia architektury kodu
Testy unitowe - cechy dobrych testów
- Testują jedną funkcjonalność – obojętnie, czy to metoda, klasa, czy paczka
- Powinny działać w izolacji – test powinien przechodzić lub nie przechodzić niezależnie od innych testów
- Są powtarzalne
- Nazwa testu mówi nam co on robi
- Testują zachowanie, ale nie detale implementacyjne
- Powinny być napisane tak, żeby tylko na ich podstawie dało się odbudować cały system
- Nie pokazują bugów w kodzie
- Nie testują metod prywatnych bezpośrednio
Kiedy NIE używamy?
- Chcemy uzyskać 100% pokrycia
Testowanie wszystkich funkcji, łącznie z getterami i setterami nie ma sensu. Jeżeli tak podstawowe funkcje nie działają, to oznacza to, że coś się dzieje z całym językiem programowania lub kompilatorem, a nie naszym kodem. - Prototypowanie
Jest to eksperyment, który ma nam pokazać, czy coś działa, czy nie działa. Nie jest to kod mający być zamieniony na prawdziwy system, więc nie trzeba wtedy robić TDD. - Game Dev
Pisanie testów do tego jak wygląda dana postać bądź jak się rusza nie ma sensu. - UI/Wygląd
Nie powinniśmy testować w którym miejscu jest Label, a którym Button. Owszem, należy przetestować, czy ten przycisk działa po kliknięciu, ale nie jego wygląd. - Data Science
Wady TDD
- Spowalnia pisanie kodu produkcyjnego
- Blokuje pisanie prostych funkcji (ponieważ najpierw trzeba jeszcze napisać do niej test)
- Przy pisaniu nieczytelnych testów każdy błąd mocno wydłuża pracę
- Niektóre testy mogą zostać tak napisane, że będą zależne od innych i błąd pierwszych będzie powodował błąd kolejnych
Przykład
Chcemy stworzyć metodę, która sprawdza czy podana liczba jest pierwsza. Jeżeli tak, zwraca wartość 'true’, jeżeli
nie- 'false’. Korzystać będę z jUnit5 i dzielić linie kody wg zasad Behavior-Driven Development.
W pierwszej kolejności tworzymy test do tej metody:
@Test
void givenPrimeNumberShouldReturnTrue(){
//given
Example example = new Example();
int number = 13;
//then
assertTrue(example.isNumberPrime(number));
}
Test nie przeszedł, ponieważ nie mamy utworzonej klasy, ani metody, którą mógłby testować. Zatem tworzymy najprostszą implementację dzięki której test przejdzie:
public class Example {
public boolean isNumberPrime(int number){
for(int i=2; i*i<=number; i++){
if(number%i==0){
return false;
}
}
return true;
}
}
Test przeszedł, znaczy że nasza metoda, dla liczby 13 jest prawidłowa. Teraz następuje faza Refactor. Przy czym nie uważam, że należy teraz coś zmienić w kodzie, więc rozpoczniemy kolejny cykl. Sprawdzimy teraz, czy funkcja zwróci nam wartość 'false’ dla 1.
@Test
void given1ShouldReturnFalse(){
//given
Example example = new Example();
int number = 1;
//then
assertFalse(example.isNumberPrime(number));
}
Test nie przeszedł, ponieważ nie mamy uwzględnionego w metodzie, że liczba 1 nie jest zaliczana do liczb pierwszych.
Czas na zaimplementowanie tego w metodzie.
public boolean isNumberPrime(int number){
if(number == 1){
return false;
}
for(int i=2; i*i<=number; i++){
if(number%i==0){
return false;
}
}
return true;
}
Test przeszedł. Teraz zastanówny się, czy możemy coś zmienić w kodzie. Nie możemy zacząć iterować w pętli for
od 1, ponieważ lieczby pierwsze dzielą się tylko przez 1 i przez samą siebie. Zacznijmy zatem kolejny cykl. Sprawdzimy teraz, czy podanie liczby ujemnej lub 0 zwróci nam wartość false (liczby pierwsze są tylko dodatnie).
@Test
void givenNegativeNumberOr0ShouldReturnFalse(){
//given
Example example = new Example();
int number = -13;
int number2 = 0;
//then
assertFalse(example.isNumberPrime(number));
assertFalse(example.isNumberPrime(number2));
}
Test jak widać czerwony, czyli przechodzimy teraz do najprostszej implementacji.
public boolean isNumberPrime(int number){
if(number <= 0){
return false;
}
if(number == 1){
return false;
}
for(int i=2; i*i<=number; i++){
if(number%i==0){
return false;
}
}
return true;
}
Wszystkie testy przechodzą. Przechodzimy do fazy refaktor i po zastanowieniu się, stwierdzam, że kod można skrócić w taki sposób:
public boolean isNumberPrime(int number){
if(number <= 1){
return false;
}
for(int i=2; i*i<=number; i++){
if(number%i==0){
return false;
}
}
return true;
}
Testy ponownie przeszły.
Sprawdzimy jeszcze, czy algorytm dobrze sprawdzi największą liczbę pierwszą dla integer.
@Test
void givenIntMaxValueShouldReturnTrue(){
//given
Example example = new Example();
int number = Integer.MAX_VALUE;
//then
assertTrue(example.isNumberPrime(number));
}
Największa wartość integer, czyli 2147483647 jest liczbą pierwszą. Oznacza to, że musimy uwzględnić to w naszej funkcji.
public boolean isNumberPrime(int number){
if(number <= 1){
return false;
}
if(number == Integer.MAX_VALUE){
return true;
}
for(int i=2; i*i<=number; i++){
if(number%i==0){
return false;
}
}
return true;
}
Test przeszedł, ale nie jest to najlepsze rozwiązanie. Myślę, że chodzi tu o pętlę for i mnożenie licznika 'i’, które przekracza maksymalną wartość dla integer. Przechodzimy zatem do fazy refactor i spróbujmy zmienić nasz algorytm.
public boolean isNumberPrime(int number){
if(number <= 1){
return false;
}
for(int i=2; i<=Math.sqrt(number); i++){
if(number%i==0){
return false;
}
}
return true;
}
Wszystkie testy przeszły. Algorytm działa.
W ten oto sposób możemy pisać cały program, nie tylko pojedyncze funkcje. Oczywiście większość metod nie będziemy testować aż tak dokładnie, ponieważ nie wszystkie metody tego potrzebują.
Podsumowanie
Test-Driven Development jest jedną z technik tworzenia oprogramowania, dlatego przy rozpoczynaniu nowego projektu należy się zastanowić, czy akurat ona będzie pasować. Jak wszystko ma swoje wady i zalety, jednak osobiście uważam ją za ciekawe rozwiązanie do rozwiązywania problemów zanim one nastąpnią.