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

  1. 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
  2. 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
  3. 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ą.