JUnit 5 - Pierwsze kroki

JUnit 5 to framework nowej generacji do tworzenia automatycznych testów jednostkowych w technologii Java, oferujący wiele ciekawych funkcji, takich jak np. testy parametryzowane, wstrzykiwanie zależności, całkowite wsparcie dla Javy 8, czy w końcu nowe API, umożliwiające łatwiejsze niż kiedykolwiek rozszerzanie możliwości frameworka i dostosowanie go do potrzeb projektowych.

Frameworki takie jak JUnit 5 wykorzystywane są dzisiaj nie tylko w testach jednostkowych. Mają również szerokie zastosowanie w testach wyższego poziomu, w tym w testach funkcjonalnych z użyciem takich bibliotek jak Selenium WebDriver czy REST Assured.

W tym wpisie przedstawię najważniejsze cechy JUnit 5, dzięki czemu nauczysz się samodzielnie przygotować projekt w oparciu o ten framework.

Dokumentacja JUnit 5

Dokumentacja JUnit 5 jest wyjątkowo dobrze napisana. Nie tylko zawiera szczegółową dokumentację frameworka, ale również wiele przykładów, które możesz wykorzystać tworząc swój projekt. To strona, która definitywnie powinna znaleźć się w Twoich ulubionych, jeśli zamierzasz uczyć się, a później korzystać z JUnit 5:

http://junit.org/junit5/docs/current/user-guide/

Przygotowanie środowiska

Do pracy z projektem opartym o JUnit 5 koniecznie jest środowisko z zainstalowanym Java Development Kit w wersji co najmniej 8 (JDK 8), zintegrowane środowisko programistyczne (Intellij IDEA Community lub Ultimate) oraz opcjonalnie system kontroli wersji Git.

Tip: Jeżeli pracujesz w systemie Windows 10, zastanów się nad użyciem menadżera pakietów takiego jak Chocolatey, który umożliwia zarządzanie wszystkimi aspektami związanymi z oprogramowaniem: instalacją, konfiguracją, aktualizacją i odinstalowaniem. Wystarczy kilka komend, aby przygotować środowisko z aktualnymi wersjami oprogramowania.

Jeśli chcesz, możesz skorzystać z opisu konfiguracji środowiska w systemie Windows, opracowanych na potrzeby projektu automatyzacji testów aplikacji internetowej z wykorzystaniem Selenium. Wiele spośród zawartych tam porad można zastosować również w opisywanym tutaj podstawowym projekcie:

Jeśli używasz systemu macOS, możesz skorzystać ze wskazówek dotyczących przygotowania środowiska zebranych w tym artykule:

Konfiguracja projektu

JUnit 5 składa się z kilku artefaktów zgrupowanych w JUnit Platform, JUnit Juipter i JUnit Vintage - w sumie z kilku bibliotek (architektura modularna), które wymagane są do prawidłowej kompilacji i uruchamiania projektów.

Pobierz junit5-gradle-template z repozytorium

Aby uprościć proces tworzenia projektu, przygotowałem gotowy do ściągnięcia szkielet aplikacji. Zaletą tego podejścia jest brak konieczności instalacji Gradle. Projekt zawiera tzw. Gradle Wrapper, który lokalnie zainstaluje zależności Gradle wymagane do jego uruchomienia.

git clone

Jeżeli masz zainstalowany system kontroli wersji Git, wystarczy sklonować repozytorium:

git clone https://gitlab.com/qalabs/blog/junit5-gradle-template.git

Tip: Dla systemu Windows istnieje sporo interesujących emulatorów konsoli, które ułatwiają codzienną pracę z wierszem poleceń. Warto zainteresować się takimi narzędziami, jak cmder lub Babun.

Nie masz Gita?

Pobierz ZIP ze strony projektu: https://gitlab.com/qalabs/blog/junit5-gradle-template i rozpakuj go na swoim komputerze.

Pierwsze budowanie projektu

Używając wiesza poleceń, przejdź do katalogu projektu (junit5-gradle-template) i wykonaj polecenie:

gradlew clean test

Komenda skompiluje kod projektu i uruchomi testy z użyciem Gradle. Jeśli wszystko zostało poprawnie skonfigurowane, powinieneś zobaczyć podsumowanie podobne do tego jak poniżej:

1
2
3
4
5
6
7
8
9
10
Starting a Gradle Daemon (subsequent builds will be faster)

> Task :test

pl.qalabs.blog.junit5.JUnit5Tests > helloJUnit5() PASSED


BUILD SUCCESSFUL in 9s
4 actionable tasks: 4 executed

Gdy utworzysz więcej testów, możesz używać powyższej komendy do wykonywania ich wsadowo (czyli właśnie z użyciem wiersza poleceń).

Import projektu do IntelliJ

Aby zaimportować projekt w IntelliJ, wybierz opcję File | Open i wskaż plik gradle.build. Otwórz plik jako projekt i poczekaj chwilę, aż zaimportuje się wraz ze wszystkimi zależnościami.

Wprowadzenie do JUnit 5

Zależności

Konfiguracja zależności znajduje się w pliku build.gradle. Najważniejsze z nich to plugin JUnit Platform dla Gradle, pozwalający uruchamiać testy, oraz Jupiter Engine i Jupiter API.

Tip: Przeczytaj więcej o definiowaniu zależności w Gradle: https://docs.gradle.org/4.5.1/userguide/artifact_dependencies_tutorial.html

Podstawowe adnotacje

Podstawowe adnotacje używane w testach pochodzą z pakietu org.junit.jupiter.api i są to:

  • @BeforeAll- metoda oznaczona tą adnotacją będzie wykonana przed wszystkimi innymi metodami w klasie
  • @BeforeEach - metoda oznaczona tą adnotacją będzie wykonana przed każdym kolejnym testem
  • @Test - właściwa metoda testowa
  • @AfterEach - metoda oznaczona tą adnotacją będzie wykonana po każdym kolejnym teście
  • @AfterAll - metoda oznaczona tą adnotacją będzie wykonana po wszystkich innych metodach w klasie

Inne przydatne adnotacje:

  • @DisplayName - pozwala na dostosowanie wyświetlanej nazwy testu
  • @Disabled - wyłącza test
  • @RepeatedTest - wykonuje test konfigurowalną liczbę powtórzeń
  • @Tag - pozwala na tagowanie testów

Podstawowy przykład:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import org.junit.jupiter.api.*;

@DisplayName("JUnit5 - Test basics")
class JUnit5BasicsTest {

@BeforeAll
static void beforeAll() {
System.out.println("Before all tests (once)");
}

@BeforeEach
void beforeEach() {
System.out.println("Runs before each test");
}

@Test
void standardTest() {
System.out.println("Test is running");
}

@Test
public void testCanBePublicTestButWhy() {
System.out.println("Test is running");
}

@DisplayName("My #2 JUnit5 test")
@Test
void testWithCustomDisplayName() {
System.out.println("Test is running");
}

@DisplayName("Tagged JUnit5 test")
@Tag("cool")
@Test
void tagged() {
System.out.println("Test is running");
}

@Disabled("Failing due to unknown reason")
@DisplayName("Disabled test")
@Test
void disabledTest() {
System.out.println("Disabled, will not show up");
}

@DisplayName("Repeated test")
@RepeatedTest(value = 2, name = "#{currentRepetition} of {totalRepetitions}")
void repeatedTestWithRepetitionInfo() {
System.out.println("Repeated test");
}

@AfterEach
void afterEach() {
System.out.println("Runs after each test");
}
}

Test możesz uruchomić bezpośrednio z IntelliJ lub możesz użyć podanej wcześniej komendy gradlew clean test.

Cykl życia instancji testu (@TestInstance)

W JUnit 5 domyślnie dla każdej metody testowej w klasie testowej zostaje utworzony nowy obiekt tej klasy. To tak zwany cykl życia per metoda (w nomenklaturze JUnit 5 to Lifecycle.PER_METHOD). Nie mniej, cykl życia instancji testu może zostać zmieniony na cykl życia per klasa (Lifecycle.PER_CLASS) za pomocą adnotacji @TestIntance. W trybie PER_CLASS pojedyncza instancja testu jest tworzona dla każdej metody testowej, a metody oznaczone adnotacjami@BeforeAll i @After nie muszą być statyczne.

Daje to bardzo ciekawe możliwości, szczególnie w testach, gdzie każda metoda testowa musi mieć dostęp do tej samej instancji danego obiektu. Takim obiektem może być np. instancja org.openqa.selenium.WebDriver w Selenium, gdzie raz zainicjowany sterownik do wybranej przeglądarki powinien być wykorzystany przez wszystkie testy w klasie testowej.

Przykład:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import org.junit.jupiter.api.*;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("JUnit5 - Test lifecycle adjustments")
class JUnit5PerClassLifecycleTest {

private Object first = new Object();
private Object second;

@BeforeAll
void beforeAll(TestInfo testInfo) {
this.second = this.first;
System.out.println("Non static before all.");
}

@BeforeEach
void beforeEach() {
Assertions.assertEquals(first, second);
}

@Test
void first() {
Assertions.assertEquals(first, second);
}

@Test
void second() {
Assertions.assertEquals(first, second);
}

@AfterAll
void afterAll() {
System.out.println("Non static after all.");
}

@AfterEach
void afterEach() {
Assertions.assertEquals(first, second);
}
}

Konfiguracja cyklu życia może być również zmieniona globalnie dla wszystkich testów. Jednym ze sposobów jest użycie konfiguracji z pliku junit-platform.properties:

junit.jupiter.testinstance.lifecycle.default = per_class

Wstrzykiwanie parametrów do testów

W JUnit 5 metody testowe oraz inne metody klasy testowej mogą przyjmować parametry typu org.junit.jupiter.api.TestInfo, org.junit.jupiter.api.RepetitionInfo czy org.junit.jupiter.api.TestReporter. Dodatkowo, dzięki bardzo prostemu Extension API, definiowanie własnych typów parametrów w JUnit 5 staje się bardzo proste.

Zwróć uwagę na metody przyjmujące parametry. Te parametry są wstrzykiwane automatycznie podczas uruchomienia testu i dają programiście dodatkowe metody, z których może korzystać:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class JUnit5BuiltInParameterResolution {

@BeforeAll
static void beforeAll(TestInfo testInfo) {
System.out.println("Before all can take parameters. Started: " + testInfo.getDisplayName());
}

@BeforeAll
static void beforeAll(TestReporter testReporter) {
testReporter.publishEntry("myEntry", "myValue");
}

@BeforeAll
static void beforeAll(TestInfo testInfo, TestReporter testReporter) {
testReporter.publishEntry("myOtherEntry", testInfo.getDisplayName());
}

@DisplayName("Repeated test")
@RepeatedTest(value = 2, name = "#{currentRepetition} of {totalRepetitions}")
void repeatedTest(RepetitionInfo repetitionInfo) {
System.out.println("Repeated test - " + repetitionInfo.toString());
}
}

Poza obiektem typu TestReporter, który daje możliwość publikowania wpisów do raportu z testów, pozostałe obiekty wbudowane (TestInfo, RepetitionInfo) mają raczej niewielkie zastosowanie w praktyce.

Nie mniej, jeżeli rozszerzymy JUnit 5 i pozwolimy mu na wstrzykiwanie własnych zależności, zastosowań praktycznych będzie znacznie więcej. Możliwe zastosowania to na przykład wstrzykiwanie obiektów konfiguracyjnych w testach oraz wstrzykiwanie obiektów dostarczających dane testowe.

Info: Rozszerzanie JUnit 5 z użyciem Extension API jest poza zakresem tego artykułu. Jeżeli jesteś zainteresowany, to znajdziesz informacje na ten temat w dokumentacji JUnit 5.

Asercje

Asercje w testach służą do weryfikacji zachowania lub stanu testowanego systemu (SUT - System Under Test). Niepowodzenie asercji kończy się w JUnit 5 wyjątkiem, który przerywa wykonanie aktualnego testu.

JUnit 5 dostarcza wiele wbudowanych asercji, których należy szukać w klasie org.junit.jupiter.api.Assertions.

Tip: Oprócz wbudowanych asercji, możesz też korzystać z bibliotek zewnętrznych. Polecam szczególnie AssertJ. Więcej o integracji AssertJ i JUnit 5 możesz przeczytać tutaj: JUnit meets AssertJ

Asercje podstawowe

Do podstawowych asercji należą: assertEquals, assertArrayEquals, assertSame, assertNotSame, assertTrue, assertFalse, assertNull, assertNotNull, assertLinesMatch, assertIterablesMatch

Przykład:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import static org.junit.jupiter.api.Assertions.assertNotNull;

@Test
void basicAssertions() {
// arrange
List<String> owners = Lists.newArrayList("Betty Davis", "Eduardo Rodriquez");

// assert
assertNotNull(owners);
assertSame(owners, owners);
assertFalse(owners::isEmpty); // Lambda expression
assertEquals(2, owners.size(), "Found owner names size is incorrect");
assertLinesMatch(newArrayList("Betty Davis", "Eduardo Rodriquez"), owners);
assertArrayEquals(
new String[]{"Betty Davis", "Eduardo Rodriquez"},
owners.toArray(new String[0])
);
}

Assert all

Assertions.assertAll weryfikuje, czy żadna z grupy asercji nie kończy się wyjątkiem:

1
2
3
4
5
6
7
8
9
10
11
12
@DisplayName("assertAll")
@Test
void assertAll() {
List<String> owners = Arrays.asList("Betty Davis", "Eduardo Rodriquez");

// assert
Assertions.assertAll(
() -> Assertions.assertTrue(owners.contains("Betty Doe"), "Contains Betty Doe"),
() -> Assertions.assertTrue(owners.contains("Eduardo Rodriquez"), "Contains Eduardo Rodriquez"),
() -> Assertions.assertTrue(owners.contains("John Doe"), "Contains John Doe")
);
}

Zwróć uwagę na wykorzystanie wyrażeń Lambda w przykładzie. Wyrażenia Lambda pozwalają na tworzenie instancji klas anonimowych, implementujących interfejs z jedną metodą w bardzo zwięzły sposób. Pozwalają one na traktowanie funkcjonalności jako argumentu do metody.

W powyższym przykładzie do metody assertAll przekazywane są dwa argumenty, które są anonimowymi implementacjami interfejsu org.junit.jupiter.api.function.Executable, wyrażonymi za pomocą wyrażenia Lambda.

Tip: Przeczytaj więcej o wyrażeniach Lamda tutaj: https://docs.oracle.com/javase/tutorial/java/javaOO/lambdaexpressions.html

Zasada działania assertAll jest dość prosta: metoda weryfikuje, czy żadna z przekazanych do tej metody asercji nie zakończy się błędem. Co istotne, w przypadku assertAll wszystkie asercje przekazane jako argumenty zostaną wykonane, nawet jeśli jedna z pierwszych da wynik negatywny, a jeśli choć jedna zakończy się wyjątkiem, to cały test zakończy się błędem.

Dla powyższego przykładu zaraportowany zostanie następujący błąd:

1
2
3
org.opentest4j.MultipleFailuresError: Multiple Failures (2 failures)
Contains Betty Doe ==> expected: <true> but was: <false>
Continas John Doe ==> expected: <true> but was: <false>

Funkcjonalność ta może mieć szerokie zastosowanie w testach z wykorzystaniem Selenium, gdzie zatrzymanie testu w wyniku pierwszego napotkanego błędu może spowodować, że inne problemy nie zostaną wykryte, a ponowne uruchomienie testów w celu weryfikacji pozostałych asercji może zająć dużo czasu.

Podsumowanie

JUnit 5 oferuje jeszcze wiele innych funkcji, takich jak choćby weryfikacja czasu wykonania metod, testy parametryzowane, czy znakomite Extension API.

Rafał Borowiec

Nazywam się Rafał Borowiec. Jestem w branży IT od ponad 10 lat, przygodę rozpoczynałem jako tester oprogramowania. Oprócz testowania oprogramowania i zapewniania jakości, specjalizuję się w wytwarzaniu oprogramowania oraz zarządzaniu projektami i zespołami. Chętnie dzielę się wiedzą, prowadzę blog dotyczący programowania, jestem wykładowcą oraz trenerem IT.