Czerwiec 6, 2019

Czy wiesz na czym polega Test Driven Development (TDD)?

Seria o testach jednostkowych

  1. Wstęp do testów jednostkowych
  2. Test Driven Development w praktyce
  3. Czy dependency injection ułatwia testowanie?
  4. Mocki w testach jednostkowych
  5. Mockito spy

Test driven development

W poprzedniej częsci serii o testach jednostkowych wprowadziłem Cię w świat pisania testów. Pokazałem Ci jak napisać swój pierwszy test, jakie narzędzia można do tego wykorzystać oraz jak wygląda pisanie testów w bardziej realnych przykładach aniżeli tylko oklepany kalkulator. Nadszedł czas na kolejną część, w tym artykule przedstawię Ci koncepcję Test Driven Development.

Nazwę koncepcji możemy zrozumieć tak, że aplikację tworzymy w oparciu o testy – testy mają być ważną częścią procesu tworzenia aplikacji. Może brzmieć to teraz trochę abstrakcyjnie, choć jak za chwile się okaże podejście TDD jest naprawdę proste – nie ma tutaj żadnego rocket science. Przejdźmy teraz do zrozumienia czym TestDrivenDevelopment jest w praktyce.

Cała koncepcja opiera się o prostą zasadę:

Zanim napiszesz docelowy kod aplikacji, napisz do niego test.

Tak w skrócie brzmi całe TDD i w sumie w tym miejscu mógłbym zakończyć pisanie tego artykułu, a Ty w sumie wiedziałbyś już na czym polega to podejście. Jednak nie o to tu chodzi – choć ta koncepcja wydaję się na naprawdę prostą to najlepiej prześledzić jej działanie w praktyce i samemu podjąć decyzję czy powinniśmy ją stosować.

Do tego czy powinniśmy ją stosować czy nie, wrócimy na sam koniec wpisu – teraz zanurzmy się w TDD!

Trzymaj się cyklu

Test Driven Development najlepiej jest opisać w formie kilku punktów – cyklu. Moim zdaniem w taki sposób najłatwiej jest zrozumieć co naprawdę musimy zrobić, choć wcześniej przytoczona zasada wydaje się klarowna i nie wymaga dodatkowe tłumaczenia.

Cykl TDD możemy rozpisać na punkty takie jak:

  1. napisz testy do kodu, który chcesz napisać;
  2. uruchom testy – większość testów nie powinna się powieść;
  3. napisz implementację metod, które testujesz;
  4. uruchom ponownie testy;
  5. punkt 3 i 4 powtarzaj do momentu, aż wszystkie testy się powiodą;

Mając już określony taki cykl teraz wszystko musi być już jasne – dostałeś właśnie gotowy algorytm jak używać podejścia Test Driven Development, więc czas go użyć na realnym przykładzie.

 

TDD na warsztacie

Przykład będzie oparty o prosty model Issue oraz jego walidator. Przypuśćmy, że sytuacja jest oparta w świecie, gdzie nasi użytkownicy mogą tworzyć tzw. issue w naszym systemie, na które my później im odpowiadamy.

W sytuacji dodawania czegokolwiek do naszego systemu zazwyczaj powinna odbyć się walidacja. W tym celu jesteśmy zmuszeni przetestować walidator, aby mieć pewność, że nienajmądrzejszy użytkownik nie nabroi nam w systemie.

 

Wspomniany model Issue wygląda tak:

package com.company;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

import java.time.LocalDateTime;

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Builder
@ToString
public class Issue {
    private String title;
    private String description;
    private LocalDateTime createDate;
    private String reporterEmail;
    private IssueType type;
}

Aby uniknąć generowania prostego kodu typu: gettery, konstruktor i builder skorzystałem z biblioteki Lombok, która generuje to wszystko za nas.

Jeszcze enum IssueType, który jest używany w powyższym modelu:

package com.company;

enum IssueType {
    BUG, FEATURE, TASK
}

Mając to wszystko możemy rozpocząć pisanie naszego issue walidatora – postępujmy zgodnie z cyklem TDD.

1. Napisz testy do kodu, który chcesz napisać

Zaczniemy oczywiście od napisania testów, chociaż punktem zero powinno być stworzenie klasy walidatora oraz zadeklarowanie metody, która będzie testowana – punkt zero realizujemy tylko w takim celu, aby kod był kompilowalny na drugim etapie.

package com.company;

public class IssueValidatorService {
    void validateIssue(Issue issue) {
    }
}

Mamy już przygotowaną klasę – możemy przejść do napisania testów, nie będę się skupiał w tym wpisie jak to dokładnie możemy zrobić – tego wszystkiego dowiesz się we wstępie do testów jednostkowych.

Do testowania kodu korzystam z biblioteki JUnit4 – tak jak to robiłem w poprzednim wpisie.

Do przetestowania mamy 5 różnych przypadków. Cztery przypadki, gdy walidacja ma się nie powieść:

  1. podana data występuje przed datą dzisiejszą;
  2. nie został wybrany typ zgłoszenia;
  3. email nie jest w poprawnym formacie;
  4. data występuje po dacie dzisiejszej;

Oraz jeden przypadek, gdy issue przeszło poprawnie walidację.

Pięć powyższych przypadków jest obsłużonych dzięki poniższym testom:

package com.company;

import org.junit.Test;

import java.time.LocalDateTime;

public class IssueValidatorServiceTest {
    private final IssueValidatorService issueValidatorService = new IssueValidatorService();

    @Test
    public void shouldPassValidation() {
        // given:
        Issue issue = Issue.builder()
                .createDate(LocalDateTime.now())
                .title("Brak możliwości zalogowania")
                .description("Nie moge się zalogować na użytkownika z loginem: pablo")
                .reporterEmail("pablo@mail.com")
                .type(IssueType.BUG)
                .build();


        // then:
        issueValidatorService.validateIssue(issue);
    }

    @Test(expected = IssueValidatorException.class)
    public void shouldThrowExceptionWhenCreateDateIsDeprecated() {
        // given:
        Issue issue = Issue.builder()
                .createDate(LocalDateTime.now().minusDays(1))
                .title("Nowy wykres")
                .reporterEmail("user@mail.com")
                .type(IssueType.FEATURE)
                .build();


        // then:
        issueValidatorService.validateIssue(issue);
    }


    @Test(expected = IssueValidatorException.class)
    public void shouldThrowExceptionWhenCreateDateIsTomorrow() {
        // given:
        Issue issue = Issue.builder()
                .createDate(LocalDateTime.now().plusDays(1))
                .title("Nowy wykres")
                .reporterEmail("user@mail.com")
                .type(IssueType.FEATURE)
                .build();


        // then:
        issueValidatorService.validateIssue(issue);
    }

    @Test(expected = IssueValidatorException.class)
    public void shouldThrowExceptionWhenIssueTypeIsNotAssign() {
        // given:
        Issue issue = Issue.builder()
                .createDate(LocalDateTime.now().plusDays(1))
                .title("Nie wiem czego chce")
                .reporterEmail("user@mail.com")
                .build();


        // then:
        issueValidatorService.validateIssue(issue);
    }

    @Test(expected = IssueValidatorException.class)
    public void shouldThrowExceptionWhenEmailFormatIsIncorrect() {
        // given:
        Issue issue = Issue.builder()
                .createDate(LocalDateTime.now().plusDays(1))
                .title("Zróbcie to")
                .reporterEmail("useratmail.com")
                .type(IssueType.TASK)
                .build();


        // then:
        issueValidatorService.validateIssue(issue);
    }
}

Zwróć uwagę, że w przypadku, gdy walidacja nie przejdzie to metoda validateIssue rzuci wyjątek IssueValidatorException.

2. Uruchom testy – większość testów nie powinna się powieść

Mamy napisane testy, zaś kod aplikacji nie jest jeszcze napisany. Czas je uruchomić – można to zrobić przy użyciu np. mavena lub bezpośrednio w swoim IDE. Ja zrobię to drugim sposobem, jest to dla mnie wygodniejsze (szczególnie, że pracuję w Intellij Idea), ponieważ mogę przeczytać odpowiednie logi/informacje powiązane z danym testem, co pomaga w dalszym naprawianiu kodu.

 

Czas wyjaśnić drugą cześć nagłówka czyli: „większość testów nie powinna się powieść”. Czasami może się zdarzyć, że test faktycznie zapali się na „zielono” (tak jak stało się to w moim przypadku). Jest to tylko kwestia przypadku, że udało się osiągnąć taki efekt. Dzieje się tak, ponieważ testujemy metodę void i nie oczekujemy żadnego efektu ubocznego w pierwszym teście.

3. Napisz implementację metod, które testujesz

Mamy napisane testy – przechodzimy do pisania długo wyczekiwanego kodu walidatora. Nie chcę się za bardzo rozpisywać jak to zaimplementowałem, ponieważ nie chcę bezsensownie wydłużać wpisu o TDD tekstem o tym jak coś zaimplementować w Javie.

package com.company;

import org.apache.commons.validator.routines.EmailValidator;

import java.time.LocalDate;

class IssueValidatorService {
    void validateIssue(Issue issue) {
        if (titleIsEmpty(issue)) {
            throw new IssueValidatorException("Issue's title can't be empty.");
        }

        if (createDateIsNotToday(issue)) {
            throw new IssueValidatorException("Create date should today's date.");
        }

        if (issueTypeIsNotAssign(issue)) {
            throw new IssueValidatorException("Issue type is not assign.");

        }

        if (isIncorrectEmailFormat(issue)) {
            throw new IssueValidatorException("Incorrect email format.");
        }
    }

    private boolean isIncorrectEmailFormat(Issue issue) {
        return EmailValidator.getInstance().isValid(issue.getReporterEmail());
    }

    private boolean issueTypeIsNotAssign(Issue issue) {
        return issue.getType() == null;
}

    private boolean createDateIsNotToday(Issue issue) {
        return LocalDate.from(issue.getCreateDate()).isEqual(LocalDate.now());
    }

    private boolean titleIsEmpty(Issue issue) {
        return issue.getTitle().isEmpty();
    }
}

W wielkim skrócie:

  • datę przechowuję w LocalDateTime – konwertuje do LocalDate, dzięki któremu sprawdzam tylko datę bez czasu;
  • do walidacji emaila korzystam z biblioteki commons-validator produkcji Apache – warto o niej pamiętać;
  • resztę kodu powinieneś być w stanie zrozumieć – jeśli coś nie jest naprawdę dla Ciebie zrozumiałe to nie wstydź się zapytać w komentarzu;

4. Uruchom ponownie testy

Wielki test kodu – czy kod napisaliśmy poprawnie. Łatwo to sprawdzić, wystarczy ponownie uruchomić wcześniej napisane testy.

Jak się okazuje nie udało nam się poprawnie zaimplementować walidatora issue, lecimy więc dalej…

5. Punkt 3 i 4 powtarzaj do momentu, aż wszystkie testy się powiodą

Aż dotarliśmy do punktu 5, który nas odsyła z powrotem do puntku 3 i 4.

Ja już tam byłem – musisz mi uwierzyć na słowo i okazało się, że w metodach: isIncorrectEmailFormat oraz createDateIsNotToday zabrakło negacji. I tylko tyle. 😉

Po poprawieniu kodu możesz zobaczyć dowód, że kod faktycznie przeszedł nasze testy.

Cały kod z warsztatu znajdziesz w tym repozytorium.

Konkluzja

Na prostym, w miarę realnym przykładzie pokazałem Ci jak można zastosować koncepcję Test Driven Development. Czy jest dobra, najlepsza i każdy powinien ją stosować?

Co ja o tym uważam? – to zależy.

Mi osobiście sama koncepcja się podoba, ma bardzo fajne i proste założenia, które mogą pomóc w pisaniu lepszego kodu.

Czy jest naprawdę taka prosta?

W tak prostym przykładzie jak ten (choć trudniejszym niż kalkulator :P) TDD sprawdza się super, jednak w sytuacji, gdy naszym zadaniem jest stworzenie dużego feature w rozbudowanej aplikacji robią się schody.

Pisany kod rozrasta się błyskawnicznie, szybko dochodzą nam nowe zależności. Powoduje to również wzrośnięcie liczby przypadków testowych, które trzeba przemyśleć/wymyśleć jeszcze przed napisaniem kodu.

Czy stosuję TDD w kodzie produkcyjnym?

Staram się, lecz nie zawsze mi to wychodzi – dla mnie jest to nawyk ciężki do wyrobienia w sobie. Może tylko dlatego, że zacząłem go stosować za późno? 😉

A czy Ty starasz się stosować Test Driven Development?

A jeśli dopiero teraz poznałeś TDD to czy planujesz go stosować w swoich projektach?