
Seria o testach jednostkowych
- Wstęp do testów jednostkowych
- Test Driven Development w praktyce
- Czy dependency injection ułatwia testowanie?
- Mocki w testach jednostkowych
- 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:
- napisz testy do kodu, który chcesz napisać;
- uruchom testy – większość testów nie powinna się powieść;
- napisz implementację metod, które testujesz;
- uruchom ponownie testy;
- 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ść:
- podana data występuje przed datą dzisiejszą;
- nie został wybrany typ zgłoszenia;
- email nie jest w poprawnym formacie;
- 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?