» Test Driven Development
Wstęp
Muszę przyznać, że obok wzorców projektowych Test Driven Development (TDD) jest kolejnym ciekawym tematem. Kiedy usłyszałem, że zamiast kompilować program, sprawdzać czy się nie wysypie przy okazji, a jeśli tak, to potem szukać wrażliwych punktów i kombinować gdzie ta igła w tym sianie się znajduje…że zamiast tego wystarczy kilka, kilkanaście, kilkadziesiąt, kilkaset (zależnie od wielkości aplikacji) funkcji testowych, które sprawdzają jak najmniejszy niepodzielny fragment logiki, to lekko się uśmiechnąłem. 🙂 Zaledwie jednym kliknięciem inicjującym proces przygotowania do kompilacji w IDE można uruchomić wszystkie napisane testy wraz z całą aplikacją. A jeśli wyskoczy gdzieś błąd w teście, to program jak zwykle się nie uruchomi, ale tym razem dostaniemy ciekawy feedback, który test nie przeszedł – co pozwoli na bardzo szybkie zlokalizowanie niedziałającego dziadostwa. To wszystko dzięki testom jednostkowym, ale! Gdy jeszcze takie testy są pisane systematycznie jak najczęściej tylko się da, to w uproszczeniu mówiąc wychodzi nam metoda TDD.
A co to wgl jest?!
Metoda pisania testów 🙂
A tak poważniej…to jest to w praktyce:
- Pisanie testu (najsampierw!)
- Pisanie kodu zdającego ten test
- Refaktoryzacja
Tak. Dokładnie w takiej kolejności.
Na co mnie na początku ten test?
Dziwne, nie? Ale ma to swoje logiczne podstawy. Jeśli najpierw napiszemy test, to:
- Zmusimy się do opisania najprościej w świecie czego my w ogóle oczekujemy od danego fragmentu kodu.
- Jeśli podczas pisania kodu zdającego ten test skupimy się tylko i wyłącznie na przejściu go najprostszym możliwym sposobem, to taki kod będzie…najprostszy z dla nas możliwych. 🙂 A to jest bardzo cenna zaleta – nie komplikujemy niepotrzebnie i tak już często skomplikowanej logiki.
- Ostatecznie jeśli dobrze wkręcimy się w TDD to wychodzi, że łatwiej napisać najpierw test, potem fragment kodu właściwego niż kod całej złożonej funkcjonalności (albo co gorsza całej aplikacji :o) a potem spędzać tygodnie zgrzytając zębami i plując sobie w brodę na zastanawianiu się jak to wszystko przetestować. (sam muszę się do tego zabrać, bo dopiero co poznałem TDD, a mam aplikację, do której nie mam ani grama testu jednostkowego – nie ukrywam, że trochę się tego obawiam)
3 fazy
Wyróżniamy trzy fazy Test Driven Development:
Red
Pisanie kodu testowego, który kończy się niepowodzeniem (często w IDE świeci się na czerwono), czyli tak w zasadzie określenie wymagań powstającej funkcjonalności. Piszemy tu wg schematu: “Mając dane to, kiedy wykonujesz to, to wtedy oczekuję tego”.
Zamieniając pogrubione wyrazy na ich angielskie odpowiedniki dostajemy:
- dane = given
- kiedy = when
- to wtedy = then
Przykład
Mając stworzoną instancję klasy Player i NPC,
kiedy wywołamy na instancji klasy Player metodę attack(),
to wtedy oczekuję, że pole “życie” instancji klasy NPC zmniejszy swoją wartość ze 100 do 90.
// given Player player = new Player(100); // 100 to wartość początkowa życia ustawiana w konstruktorze NPC npc = new NPC(100); // when player.attack() // then assertThat(npc.getHealth)).isEqualTo(100 - 10);
Green
Pisanie najprostszego możliwego kodu funkcjonalności, który po prostu zdaje test i nic więcej. Ważne jest, aby sfokusować się na prostocie implementacji, bo inaczej łatwo jest popaść w grzech przepychu i przesady, a liczba pytań WTF/minutę (w wolnym tłumaczeniu “a po co to?” na minutę) u kolegów z zespołu zacznie rosnąć w zastraszającym tempie.

Refactor
Refaktoryzacja kodu, jeśli jest konieczna, aby zachować na przykład zasady czystego kodu (również, żeby uniknąć liczby pytań WTF/minutę oraz, żeby nie uciekać potem przed resztą zespołu, gdy trzeba będzie dodać modyfikacje do naszego kodu).
Dodatkowo…
…żadna faza nie powinna być pomijana. Jeżeli po napisaniu metody testowej, która powinna NIE przechodzić, mamy zielone światło oznaczające, że już wcześniej napisany kod spełnia kryteria nowej metody testowej to jest duża szansa, że z metodą testową jest coś nie tak. Ale nie ma pewności, bo czasami podobno zdarza się, że aktualnie już zaimplementowana funkcjonalność „niechcący” spełnia i nowy test (tylko wtedy powstaje zgrzyt z koncepcją YAGNI, ale wiem, że mało widziałem do tej pory i może jest taka możliwość, że kod był napisany poprawnie), dlatego sugeruje się zawsze po prostu sprawdzić test w takich przypadkach. Tak samo, gdy nie wyszliśmy jeszcze z fazy „Red”, nie powinniśmy pisać innego kodu, który byłby niezwiązany z zaliczeniem aktualnego testu. Pamiętajmy, że to komunikat kompilatora mówi nam w jakiej fazie się znajdujemy, a nie my sami. 🙂
Wyjątkiem może być faza refactor, gdyż może się zdarzyć tak, że nie będzie konieczny refactoring dopiero co napisanego kodu. Ale polecam, żeby to była dobrze przemyślana decyzja nie wynikająca z lenistwa. Gdy nam się nie chce, to moim zdaniem lepiej już zrobić sobie przerwę i nie wyrobić się w terminie. Poza tym moje doświadczenie jest takie, że jeśli już zaczyna brakować mi siły (czego efektem jest tzw. „niechcemisizm” – powszechna choroba XXI wieku), to moja wydajność drastycznie spada. Wtedy naturalnym efektem jest opóźnienie w pracy, co jednoznacznie prowadzi do niewyrobienia się z narzuconym deadlinem. Kiedy i jak organizować sobie przerwę, to też wielka umiejętność, której sam również nie mam jeszcze dobrze wyrobionej…
Praktyka (podobno czyni mistrza)
Napiszemy teraz “kilka” linijek kodu, bo wg mnie, o co w TDD chodzi, najlepiej zrozumieć podczas pisania konkretnej aplikacji.
Załóżmy, że tworzymy grę RPG, na przykład w stylu Gothica. Uwaga! We wklejanym kodzie będę zamieszczał tylko te najistotniejsze importy. Te, które prowadzą do klas stworzonych w tym przykładowym projekcie, będę raczej pomijał.
Przygotowanie
Ja będę używał IntelliJa wraz z narzędziem Maven do zarządzania bibliotekami a także biblioteki JUnit 5, mockito oraz assertj do testowania kodu dlatego zakładam, że jako tako macie to wszystko opanowane.
Odpalamy IDE. W IntelliJ jeśli nie mamy otwartego projektu na którym wcześniej pracowaliśmy, to powinno się wyświetlić takie okienko:

…lub jeśli otworzył nam się uprzednio rozwijany projekt to klikamy najpierw kolejno w File => New => Project. Powinno nam się wyświetlić takie okienko:

Po lewej stronie wybieramy Mavena. Ja korzystam z Javy 11, więc zaznaczam odpowiednie SDK na liście u góry i klikam „Next”.

Jako że tworzymy RPG-a, to:
- Name: MyFirstRPG
- lokalizację zostawiam domyślną
- GroupId: me.<nickname, imię, cokolwiek> (dobrze jest ewentualnie wpisać nazwę swojej domeny, jeżeli się takową posiada)
- ArtifactId: powinno samo się wypełnić tym, co wpisywaliśmy do pola „Name”
- Version: zostawiam 1.0 (bez „-SNAPSHOT”, chociaż to chyba nie ma większego znaczenia dla działania aplikacji)
Klikamy „Finish” i gotowe – mamy nowy projekt o następującej strukturze folderów:

Do pliku pom.xml dodajemy:
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>11</source> <target>11</target> </configuration> </plugin> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.0</version> </plugin> </plugins> </build> <dependencies> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-launcher</artifactId> <version>1.0.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>5.3.2</version> <scope>test, compile</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>${junit.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>2.24.0</version> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.21.0</version> </dependency> </dependencies>
Między znaczniki <properties></properties> wrzucamy:
<java.version>11</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <junit.version>5.0.0</junit.version>
<junit.version> tworzy nam w pliku „pom” zmienną o nazwie „junit.version”, którą będziemy używać przy dependencjach.
Ostatecznie cały plik pom.xml wygląda u mnie jakoś tak:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>me.naioku</groupId> <artifactId>MyFirstRPG</artifactId> <version>1.0</version> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>11</source> <target>11</target> </configuration> </plugin> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.0</version> </plugin> </plugins> </build> <properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> <java.version>11</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <junit.version>5.0.0</junit.version> </properties> <dependencies> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-launcher</artifactId> <version>1.0.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>5.3.2</version> <scope>test, compile</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>${junit.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>2.24.0</version> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.21.0</version> </dependency> </dependencies> </project>
Dependencje:
- junit-platform-launcher
- junit-jupiter-api
- junit-jupiter-engine
…są potrzebne do prawidłowego działania testów. Natomiast pozostałe będę wyjaśniał w miarę pisania kodu testowego.
Znaczniki <scope></scope> nie są konieczne w naszym przykładzie, ale moim zdaniem dobrze jest odruchowo je umieszczać, gdyż <scope>test</scope> sprawia, że podczas eksportu nie wypuszczamy do pliku .jar kodu naszych testów. Użytkownikowi są one do niczego niepotrzebne.
Klikamy w ikonę odświeżenia w prawym górnym rogu IDE.


„me.naioku.my_first_rpg” to będzie główna paczka, do której ja będę wrzucał kolejne klasy/paczki.
Jednostki
Zacznijmy może od tworzenia jednostki, którą będziemy poruszać (Playera) i jednostek, komputera (tzw. NPC).

public abstract class Entity {} public class Player extends Entity {} public class NPC extends Entity {}
Przejdźmy do klasy Player i wciśnijmy jednocześnie [Ctrl + Shift + T], a w okienku, które wyskoczy po prostu kliknijmy w przycisk „Ok”. Spowoduje to utworzenie w równoległym katalogu testowym klasy PlayerTest, która zostanie osadzona w takiej samej ścieżce, co nasza klasa Player. Zawsze możemy zrobić to ręcznie przechodząc do folderu „test/java” i tam po utworzeniu tego samego zestawu paczek „me.naioku.my_first_rpg.entities” tworzymy klasę „PlayerTest”.
I teraz… Jaka jest pierwsza faza TDD? (jeśli nie pamiętacie, to zapraszam na górę artykułu) Zatem… Nie będzie dla nikogo zaskoczeniem, jeśli zacznę od napisania pierwszego testu jednostkowego. W takim razie kolejne pytanie. Czego potrzebuje nasza postać? No na przykład musi móc się poruszać. Jeśli mówimy o poruszaniu, to dobrze jest ustalić jakieś kierunki a także układ, w którym będziemy się przemieszczać. Proponuję zacząć od układu 2D o współrzędnych nazwanych standardowo x i y. Dlatego dobrze mieć dedykowaną klasę, na której będziemy mogli pracować. Zatem w nowej paczce „helpers” umieszczonej w głównej lokalizacji „my_first_rpg”, tworzymy klasę pomocniczą Coordinates.

Tam wrzucamy pola zmiennych x i y wraz z konstruktorem a także getterami i setterami dla swobodnego dostępu do tych wartości podczas przesuwania jednostki do przodu, do tyłu, w lewo, w prawo.
public class Coordinates { private double x; private double y; public Coordinates(double x, double y) { this.x = x; this.y = y; } public double getX() { return x; } public void setX(double x) { this.x = x; } public double getY() { return y; } public void setY(double y) { this.y = y; } }
W klasie Player możemy już dodać pole „coordinates” a także ustawić konstruktor, w którym to obiekt klasy Coordinates przekażemy. Warto też dodać getter, aby móc sprawdzać jakie są aktualne współrzędne tej jednostki.
public abstract class Player { Coordinates coordinates; public Player(Coordinates coordinates) { this.coordinates = coordinates; } public Coordinates getCoordinates() { return coordinates; }; }
I teraz, gdy mamy już wszystko przygotowane, możemy jeszcze raz zapytać się: Co chcemy, żeby nasza jednostka robiła?”. Zacznijmy od poruszania się na północ. Czyli: „Mając danego Playera, który znajduje się w położeniu (0; 0), kiedy wykonuję moveNorth(5), to oczekuję, że jednostka zmieni koordynaty z (0; 0) na (0; 5).
import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class PlayerTest { @Test void movingNorthShouldAddSpecificValueToYCoordinate() { // given Player player = new Player(new Coordinates(0, 0)); // when player.moveNorth(5); // then assertThat(player.getCoordinates()).isEqualTo(new Coordinates(0, 5)); } }
Uwaga! Użycie biblioteki assertj
Funkcja assertThat() pochodzi właśnie z biblioteki assertj i do jej użycia niezbędna jest właśnie poniższa dependencja, którą już dodaliśmy wcześniej:
<dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.21.0</version> </dependency>
Pro tip. Gdy chcecie skorzystać z tej funkcji, to napiszcie „assertThat()”, potem przenieście migający kursor wskazujący, gdzie następny znak zostanie postawiony, tuż przed sam nawias otwierający, a następnie kliknijcie [Alt + Enter]. IntelliJ powinien Wam wyświetlić coś takiego:

Klikacie „Import static method” i dalej wybieracie „Assertions.assertThat”.

Wtedy automatycznie zostanie zaimportowana paczka w której znajduje się metoda statyczna assertThat(). Jeżeli coś pójdzie nie tak i nie uda Wam się tak zrobić, to zawsze możecie dodać import ręcznie – jest on obecny w kodzie powyżej zaraz nad klasą (jedyny statyczny import).
Wracając do metody testowej…
Gdy uruchomimy nasz pierwszy test, to takie coś zwróci oczywiście błąd, bo nie ma takiej metody w klasie Player. Zatem faza RED została zakończona. Następną jest…? (jak poprzednio, jeśli nie pamiętacie, to zapraszam do góry 🙂 ) W takim razie teraz szukamy rozwiązania zachowując jak największy minimalizm. Metody potrzebujemy w klasie Player, nie? No to przechodzimy do klasy Player i tam piszemy naszą nową metodę. Poruszamy się na północ, więc tak jakby w górę, czyli w stronę dodatnich wartości osi Y.
public class Player extends Entity { public Player(Coordinates coordinates) { super(coordinates); } public void moveNorth(double distance) { coordinates.setY(coordinates.getY() + distance); } }
Uruchamiamy test, czyli klikamy gdzieś w obszarze funkcji testowej LPM (lewym przyciskiem myszy), a następnie [Ctrl + Shift + F10] lub po lewej stronie, przy numeracji linijek jest taki zielony trójkąt zaraz obok nagłówka metody. Klikamy w niego a potem „Run”.
Test wychodzi na zielono, czyli zdane i można iść na piwo (xd). Ale poważnie. Niektórzy mówią, że każdy taki zielony test to mały zastrzyk dopaminy w stylu – tak, udało się – i że takie drobne zwycięstwa sprawiają, że jeszcze bardziej chce się pisać kod krok po kroku. Wydajność większa, zabawa większa, czego chcieć jeszcze?
Ostatnia faza – faza refactoringu (nie będę już Was męczył scrollowaniem xd) – w tym przypadku (wg mnie) nie jest akurat potrzebna, bo za wiele kodu nie napisaliśmy. Można by tę jedną linijkę rozbić na dwie, żeby była czytelniejsza, ale wydaje mi się, że jeszcze nie jest z nią tak źle…
Spróbujmy podobnym sposobem zaimplementować poruszanie się we wszystkich czterech kierunkach!
// Testy @Test void movingSouthShouldSubtractSpecificValueFromYCoordinate() { // given Player player = new Player(new Coordinates(0, 0)); // when player.moveSouth(5); // then assertThat(player.getCoordinates()).isEqualTo(new Coordinates(0, -5)); } @Test void movingWestShouldAddSpecificValueToXCoordinate() { // given Player player = new Player(new Coordinates(0, 0)); // when player.moveWest(5); // then assertThat(player.getCoordinates()).isEqualTo(new Coordinates(5, 0)); } @Test void movingEastShouldSubtractSpecificValueFromXCoordinate() { // given Player player = new Player(new Coordinates(0, 0)); // when player.moveEast(5); // then assertThat(player.getCoordinates()).isEqualTo(new Coordinates(-5, 0)); } // Metody właściwe public void moveSouth(double distance) { coordinates.setY(coordinates.getY() - distance); } public void moveWest(double distance) { coordinates.setX(coordinates.getX() + distance); } public void moveEast(double distance) { coordinates.setX(coordinates.getX() - distance); }
Aktualnie możemy poruszać jednostką gracza. Ale co z poruszaniem się NPC-ów? Moglibyśmy tak samo po kolei implementować mechanizm zmiany pozycji. No to do dzieła! Na koniec Wasza klasa NPC razem z konstruktorem, polem coordinates, getterem i wszystkimi testami powinna wyglądać tak:
// NPC public class NPC { Coordinates coordinates; public NPC(Coordinates coordinates) { this.coordinates = coordinates; } public void moveNorth(double distance) { coordinates.setY(coordinates.getY() + distance); } public void moveSouth(double distance) { coordinates.setY(coordinates.getY() - distance); } public void moveWest(double distance) { coordinates.setX(coordinates.getX() + distance); } public void moveEast(double distance) { coordinates.setX(coordinates.getX() - distance); } public Coordinates getCoordinates() { return coordinates; }; } // NPCTest import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class NPCTest { @Test void movingNorthShouldAddSpecificValueToYCoordinate() { // given NPC npc = new NPC(new Coordinates(0, 0)); // when npc.moveNorth(5); // then assertThat(npc.getCoordinates()).isEqualTo(new Coordinates(0, 5)); } @Test void movingSouthShouldSubtractSpecificValueFromYCoordinate() { // given NPC npc = new NPC(new Coordinates(0, 0)); // when npc.moveSouth(5); // then assertThat(npc.getCoordinates()).isEqualTo(new Coordinates(0, -5)); } @Test void movingWestShouldAddSpecificValueToXCoordinate() { // given NPC npc = new NPC(new Coordinates(0, 0)); // when npc.moveWest(5); // then assertThat(npc.getCoordinates()).isEqualTo(new Coordinates(5, 0)); } @Test void movingEastShouldSubtractSpecificValueFromXCoordinate() { // given NPC npc = new NPC(new Coordinates(0, 0)); // when npc.moveEast(5); // then assertThat(npc.getCoordinates()).isEqualTo(new Coordinates(-5, 0)); } }
I teraz zatrzymałbym się na chwilę. Mamy dokładnie to samo zaimplementowane w dwóch podobnych klasach Player i NPC, ale po co tak? Czy nie można byłoby zaimplementować tego raz w jakiejś wspólnej klasie? Można. Stwórzmy sobie abstrakcyjną klasę „Entity”. Abstrakcyjna, bo przecież nie będzie w naszej grze takiej zwykłej tak po prostu jednostki. Będziemy mieli Playera i NPC, które to zarazem są jednostkami. To tak jakbyśmy mieli na świecie zwierzę ssak – po prostu. A nie ma takiego gatunku przecież. Ssaki to jakaś grupa zwierząt. Dzięki tej klasie Entity, gdy tam zaimplementujemy poruszanie się, to ten mechanizm zostanie odziedziczony zarówno przez Playera jak i NPC. I właśnie w tym momencie – po ukończonej fazie green ósmego testu jednostkowego klaruje ciekawa okazja do refaktoryzacji. Zatem przenieśmy wszystkie metody odpowiedzialne za ruch z klasy Player do klasy Entity! Po zakończonej refaktoryzacji możemy jeszcze raz odpalić nasze testy i sprawdzić czy niczego nie zepsuliśmy. Ostatecznie wszystko wygląda u mnie tak:

// Entity public abstract class Entity { Coordinates coordinates; public Entity(Coordinates coordinates) { this.coordinates = coordinates; } public void moveNorth(double distance) { coordinates.setY(coordinates.getY() + distance); } public void moveSouth(double distance) { coordinates.setY(coordinates.getY() - distance); } public void moveWest(double distance) { coordinates.setX(coordinates.getX() + distance); } public void moveEast(double distance) { coordinates.setX(coordinates.getX() - distance); } public Coordinates getCoordinates() { return coordinates; }; } // Player public class Player extends Entity { public Player(Coordinates coordinates) { super(coordinates); } } // NPC public class NPC extends Entity { public NPC(Coordinates coordinates) { super(coordinates); } }
Stub & Mock
Dodajmy sobie teraz możliwość zaatakowania innej jednostki. Potrzebujemy broni! Nowa broń zatem nowy obiekt. Ja stworzę sobie klasę LongSword w nowej paczce weapons i analogicznie jak poprzednio skrótem klawiszowym stworzę adekwatną klasę testową LongSwordTest.

Zatem napiszmy w naszej klasie testowej metodę, która pozwoli nam wyklarować to, co potrzebujemy zaimplementować. Żebyśmy w ogóle mogli zaatakować kogokolwiek, potrzebne nam jest spełnienie trzech warunków:
- Jednostka musi posiadać przede wszystkim metodę ataku
- Broń musi mieć zdefiniowaną liczbę zadawanych obrażeń + jednostka musi mieć jakiś pasek życia
- Jednostki muszą być w odpowiednim dystansie (tym zajmiemy się na samym końcu, bo dystans będzie liczony względem tego, gdzie znajdują się jednostki atakująca i otrzymująca obrażenia, a nie gdzie znajduje się broń)
import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class LongSwordTest { @Test void targetShouldBeDamagedWithProperValueWhenDistanceIsSufficient() { // given LongSword longSword = new LongSword(); NPC npc = new NPC(new Coordinates(0, 5), 100); // when longSword.attack(npc); // then assertThat(npc.getHealth()).isEqualTo(100 - 10); } }
Zajmijmy się najpierw życiem… Dodajemy pole „health” do klasy Entity. Uzupełniamy również je w wywołaniach metody super() w konstruktorach klas pochodnych Player i NPC. Modyfikujemy również metody testowe, które teraz wymagają aby podać życie jednostki podczas tworzenia jej. Moglibyśmy utworzyć dodatkowy konstruktor, ale ostatecznie nie chcemy przecież mieć możliwości stworzenia jednostki, która nie będzie posiadała pola „życie”, nie? Od razu dodajemy getter do pobierania wartości życia i setter do jej ustawiania w wypadku zadania obrażeń.
public abstract class Entity { Coordinates coordinates; double health; public Entity(Coordinates coordinates, double health) { this.coordinates = coordinates; this.health = health; } public double getHealth() { return health; } public void setHealth(double health) { this.health = health; } // dalej są metody poruszania się i getter pola coordinates // ... }
Dalej możemy przejść do metody atakującej. W klasie LongSword tworzymy metodę attack() przyjmującą parametr klasy Entity. Potrzebujemy, aby ta metoda zredukowała życie jednostki atakowanej o odpowiednią wartość (ja w teście zapisałem akurat 10). Zatem można stworzyć dodatkowe pole o nazwie „attackDamage” z przypisaną domyślną wartością „10”, a następnie okodować zmianę życia jednostki atakowanej.
public class LongSword { double attackDamage = 10; public void attack(Entity entity) { entity.setHealth(entity.getHealth() - attackDamage); } }
Wszystko okej, ale gdzie tutaj jest sprawdzanie odpowiedniego dystansu? No właśnie nie ma, dlatego musimy to dodać. Zmienić życie jednostki atakowanej możemy tylko i wyłącznie wtedy, gdy nasza broń jest na tyle długa, że ją sięgnie. Ale załóżmy, że nie chcemy teraz myśleć nad bardziej skomplikowaną logiką i w sumie testujemy przypadek, w którym zakładamy, że ten odpowiedni dystans jest zachowany. I tutaj możemy obrać jedną z dwóch dróg:
- Stubowanie
- Mockowanie
Stub
Stwórzmy sobie interfejs, który będzie miał zaimplementowaną metodę „sprawdzającą” dystans między jednostkami, a potem uzupełnijmy klasę LongSword o odpowiednie linijki.

public interface DistanceService { boolean isDistanceSufficient(); }
public class LongSword { double attackDamage = 10; DistanceService distanceService; public LongSword(DistanceService distanceService) { this.distanceService = distanceService; } public void attack(Entity entity) { if (distanceService.isDistanceSufficient()) { entity.setHealth(entity.getHealth() - attackDamage); } } }
I teraz możemy stworzyć sobie tzw. stuba. W paczce „weapons” w katalogu testowym tworzymy zwykłą klasę DistanceServiceStub.

Implementujemy w klasie DistanceServiceStub uprzednio stworzony interfejs DistanceService, a razem z nim potrzebną metodę isDistanceSufficient().
public class DistanceServiceStub implements DistanceService { public boolean isDistanceSufficient() { return true; } }
Teraz wystarczy wprowadzić małą poprawkę do kodu testowego, gdyż wymagamy aktualnie, żeby w konstruktorze klasy LongSword podać obiekt klasy DistanceService. Podajemy nasz obiekt zastępczy, czyli stuba. Cała metoda testowa przybierze następującą postać:
import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class LongSwordTest { @Test void targetShouldBeDamagedWithProperValueWhenDistanceIsSufficient() { // given LongSword longSword = new LongSword(new DistanceServiceStub()); NPC npc = new NPC(new Coordinates(0, 5), 100); // when longSword.attack(npc); // then assertThat(npc.getHealth()).isEqualTo(100 - 10); } }
W ten sposób docieramy do działającego testu, a zarazem do poprawnej implementacji funkcjonalności ataku z użyciem obiektu typu „stub”. O co tu chodzi? Jest to najzwyklejsza na świecie implementacja interfejsu. Jeśli nie chcemy się skupiać aktualnie nad logiką innej klasy, wystarczy, że napiszemy interfejs ze wszystkimi potrzebnymi metodami, jakie chcemy zamieścić w danej klasie, a potem w katalogu testowym tworzymy klasę, w której umieścimy najprostsze implementacje tych metod. Najprostsze, czyli na przykład zwracające potrzebne nam wartości – potrzebujemy „true”, to niech metoda po prostu zwraca „true”. W ten sposób oddzielamy testowanie logiki jednej klasy od drugiej. Innym bardzo dobrym zastosowaniem takiego stuba, może być oddzielenie logiki klasy wysyłającej zapytania do bazy danych, bo w tym przypadku nie przechodzące testy mogą być związane z błędami wynikającymi z samego połączenia. I taki test raz może działać, a raz nie.
Mock
A jeśli nie chcemy tworzyć dodatkowej klasy, to możemy skorzystać z drogi mockowania. Do tego właśnie będzie potrzebna biblioteka mockito:
<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>2.24.0</version> </dependency>
Interfejs możemy sobie zostawić. Może to być również zwykłą klasa. Klasę stubową usuwamy i w metodzie testowej tworzymy obiekt typu „mock”, który potem przekazujemy w konstruktorze do obiektu klasy LongSword. Ostatnią zmianą jakiej potrzebujemy, to powiedzieć kompilatorowi, co ma zrobić w przypadku wywołania naszej metody isDistanceSufficient(), czyli coś podobnego gdy tworzyliśmy metodę stubową. Tylko tutaj wszystko dzieje się wewnątrz klasy testowej – nie ma potrzeby tworzenia dodatkowych klas w katalogu testowym. Metody given() i when() są sobie równoważne i możemy wybrać tę, która nam bardziej odpowiada podczas czytania testu.
import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; // import static org.mockito.Mockito.when; class LongSwordTest { @Test void targetShouldBeDamagedWithProperValueWhenDistanceIsSufficient() { // given DistanceService distanceService = mock(DistanceService.class); LongSword longSword = new LongSword(distanceService); NPC npc = new NPC(new Coordinates(0, 5), 100); given(distanceService.isDistanceSufficient()).willReturn(true); // lub // when(distanceService.isDistanceSufficient()).thenReturn(true); // when longSword.attack(npc); // then assertThat(npc.getHealth()).isEqualTo(100 - 10); } }
W sytuacjach, gdy potrzebujemy zwrócenia czegoś bardziej skomplikowanego, jak na przykład w przypadku baz danych, gdy jest potrzeba stworzenia i zwrócenia roboczych rekordów, możemy takie wpisy utworzyć w prywatnej metodzie znajdującej się wewnątrz klasy testowej, a następnie zwrócić słowem kluczowym „return” listę takich obiektów. Wystarczy że potem podmienimy „true” na wywołanie funkcji zwracającej nam konkretną listę obiektów. Ja zrefaktoryzuję ten kod adekwatnie do tego, czego aktualnie potrzebujemy w naszym przykładzie – zwrócenia „true”.
import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; // import static org.mockito.Mockito.when; class LongSwordTest { @Test void targetShouldBeDamagedWithProperValueWhenDistanceIsSufficient() { // given DistanceService distanceService = mock(DistanceService.class); LongSword longSword = new LongSword(distanceService); NPC npc = new NPC(new Coordinates(0, 5), 100); given(distanceService.isDistanceSufficient()).willReturn(idDistanceSufficient()); // ZMIANA! // lub // when(distanceService.isDistanceSufficient()).thenReturn(idDistanceSufficient()); // when longSword.attack(npc); // then assertThat(npc.getHealth()).isEqualTo(100 - 10); } // ZMIANA! private boolean idDistanceSufficient() { return true; } }
Kod źródłowy
Jeśli ktoś z Was gdzieś się zgubił, coś nie wychodzi tak jak powinno, to podrzucam jeszcze tutaj linki do repozytorium całego kodu użytego w tym przykładzie.
PS: W „Stub example” jest pewna nieścisłość w pliku pom.xml. Mianowicie nie powinno być tam biblioteki „junit-jupiter-params”, gdyż wcale jej nie użyliśmy. W „Mock example” jest już ona usunięta.
Zakończenie
Jeśli to czytasz, to prawdopodobnie znaczy, że poświęciłaś/eś bardzo dużo czasu i za niego chcę Ci podziękować. 🙂 No chyba, że przeskrolowałaś/eś tylko do tego momentu. :p Mam tylko na dzieję, że nie sprawiłem, że ten czas był dla Ciebie zmarnowany i wyniosłeś/aś coś przydatnego z tej lektury. Dobrego dnia, nocy (pory podczas, której to czytasz)!
It is much more difficult to judge oneself than to judge others. If you succeed in judging yourself rightly, then you are indeed a man of true wisdom.
-- Little Prince --