JUnit 5 i Selenium - Testy powtarzane

Testy powtarzane (ang. repeated tests) pozwalają wykonywać ten sam przypadek testowy wielokrotnie i są szczególnie przydatne na przykład gdy chcemy zweryfikować, czy dla tych samych parametrów wejściowych system zwraca taki sam rezultat niezależnie od ilości wywołań.

W przykładzie wykorzystam mechanizm testów powtarzanych do stworzenia kilku zadań na liście, a następnie usunę te zadania za pomocą metody testowej, która zostanie wykonana jako ostatnia metoda w klasie testowej. Przykład ten wybiega poza klasyczne użycie testów powtarzanych, ale pozwoli na zademonstrowanie dodatkowych mechanizmów dostępnych w JUnit 5, takich jak zmiana cyklu życia klasy testowej oraz określenie kolejności wykonania metod testowych w klasie testowej.

O serii

Czytasz część 6 serii artykułów na temat automatyzacji testów aplikacji internetowej TodoMVC. Pozostałe części:

Kod źródłowy

Kod źródłowy opracowany w poprzednim artykule znajduje się w repozytorium Git: https://gitlab.com/qalabs/blog/junit5-selenium-todomvc-example w branchu 5-selenium-page-factory. Zmiany opisywane w tym artykule znajdują się w pakiecie pl.qalabs.blog.junit5.selenium.todomvc projektu, w branchu 6-repeated-tests.

Scenariusz zarządzania zadaniem

Przygotowanie scenariusza testowego rozpocznę od stworzenia metody testowej manageTodo(), która będzie obejmowała następujące kroki:

  • Utworzenie zadania
  • Zmiana nazwy zadania
  • Zakończenie zadania
  • Pokazanie listy aktywnych zadań
  • Pokazanie listy ukończonych zadań
  • Powrót na listę wszystkich zadań

Dzięki wykorzystaniu wzorca Page Object, utworzenie testu nie stanowi problemu:

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
class ManageTodoTest {

private WebDriver driver;
private TodoMvc page;

@BeforeAll
static void beforeAll() {
WebDriverManager.chromedriver().setup();
}

@BeforeEach
void beforeEach() {
driver = new ChromeDriver();
page = PageFactory.initElements(driver, TodoMvc.class);
page.navigateTo();
}

@AfterEach
void afterEach() {
driver.quit();
}

@Test
@DisplayName("Creates, renames and completes Todo with given name")
void managesTodo() {

var todo = "Todo 1";

// creates the task
page.createTodo(todo);
assertTrue(page.todoExists(todo));
assertEquals(1, page.getTodoCount());

// rename the task
var newTodoName = todo + "'";
page.renameTodo(todo, newTodoName);
assertTrue(page.todoExists(newTodoName));

// complete the task
page.completeTodo(newTodoName);
assertEquals(0, page.getTodosLeft());

// switch to active tasks
page.showActive();
assertEquals(0, page.getTodoCount());

// switch to completed tasks
page.showCompleted();
assertEquals(1, page.getTodoCount());

// switch to all tasks
page.showAll();
assertEquals(1, page.getTodoCount());
}
}

Więcej na temat wzorca Page Object znajdziesz w poprzednich artykułach serii: JUnit 5 i Selenium - Wprowadzenie do wzrorca Page Object oraz JUnit 5 i Selenium - klasa PageFactory z pakietu support biblioteki Selenium.

Testy powtarzane (@RepeatedTest)

Powyższy scenariusz można bardzo łatwo wykonywać wielokrotnie, wystarczy dla każdego kolejnego wykonania zmienić nazwę zadania oraz w jakiś sposób zapisywać jego aktualny numer. W tym celu możemy użyć wbudowanego w JUnit 5 mechanizmu testów powtarzanych. Aby stworzyć taki test, wystarczy zamienić adnotację @Test na @RepeatedTest, podając przy tym liczbę powtórzeń jako jej atrybut value:

1
2
3
4
5
6
7
@RepeatedTest(value = 3)
@DisplayName("Creates, renames and completes Todo with given name")
void managesTodo() {
var todo = "Todo 1";

// ...
}

Natomiast aby śledzić aktualny stan dla testów powtarzanych, a dokładnie numer powtórzenia oraz liczbę powtórzeń, możemy skorzystać z obiektu RepetitionInfo, który wstrzykujemy do metody testowej jako parametr. Obiekt repetitionInfo dostarcza dwie metody: getCurrentRepetition() oraz getTotalRepetitions(), zwracające odpowiednio aktualny numer wykonania testu oraz całkowitą liczbę wykonań.

1
2
3
4
5
6
7
@RepeatedTest(value = 3)
@DisplayName("Creates, renames and completes Todo with given name")
void managesTodo(RepetitionInfo repetitionInfo) {
var todo = "Todo 1";

// ...
}

Rezultat wykonania testów:

1
2
3
4
5
6
7
8
9
10
11
$ ./gradlew test --tests ManageTodoTest

> Task :test

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > managesTodo(RepetitionInfo)[1] PASSED

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > managesTodo(RepetitionInfo)[2] PASSED

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > managesTodo(RepetitionInfo)[3] PASSED

BUILD SUCCESSFUL in 14s

Nietrudno zauważyć, że dla każdego powtórzenia testu została utworzona nowa metoda testowa zawierająca wygenerowaną nazwę testu.

Dostosowanie nazwy wygenerowanych metod testowych

Nazwę testu powtarzanego można dostosować do własnych potrzeb. Wystarczy użyć atrybutu name adnotacji @RepeatedTest, tak jak zostało to pokazane poniżej:

1
2
3
4
5
6
7
@RepeatedTest(value = 3, name = RepeatedTest.LONG_DISPLAY_NAME)
@DisplayName("Creates, renames and completes Todo with given name")
void managesTodo(RepetitionInfo repetitionInfo) {
var todo = "Todo 1";

// ...
}

Na dzień pisania tego artykułu w przedstawionym mechanizmie występuje błąd, który powoduje, że nazwy testów wypisywane w konsoli (przez ConsoleLauncher) są nieprawidłowe. Natomiast zarówno w IDE, jak i w wygenerowanym raporcie HTML, nazwy przedstawiane są prawidłowo.

Poniżej fragment raportu HTML (build/reports):

…oraz zrzut ekranu z Intellij:

Zmiana cyklu życia klasy testowej (@TestInstance)

Łatwo zauważyć, że czas wykonania testów jest dość długi. Dzieje się tak, ponieważ przed każdym testem inicjalizujemy nową instancję sterownika, co powoduje uruchomienie nowej instancji przeglądarki dla każdego testu. Możemy to zmienić inicjalizując sterownik tylko raz - przed wszystkimi testami. W tym celu musimy przenieść kod z metod @BeforeEach i @AfterEach odpowiednio do metod @BeforeAll i @AfterAll, które domyślnie muszą być metodami statycznymi. To spowoduje konieczność dalszych modyfikacji - pola instancyjne driver i page wówczas również muszą być statyczne. Wynika to z faktu, że JUnit 5 dla każdego testu tworzy nową instancję obiektu klasy testowej.

Jest to domyślny cykl życia testu w JUnit 5, który można dość łatwo zmienić używając adnotacji @TestInstance na klasie testowej:

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
@DisplayName("Repeated test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ManageTodoTest {

private WebDriver driver;
private TodoMvc page;

@BeforeAll
void beforeAll() {
WebDriverManager.chromedriver().setup();

driver = new ChromeDriver();
page = PageFactory.initElements(driver, TodoMvc.class);
page.navigateTo();
}

@AfterAll
void afterAll() {
driver.quit();
}

@RepeatedTest(3)
@DisplayName("Creates, renames and completes Todo with given name")
void managesTodo(RepetitionInfo repetitionInfo) {

}
}

Cykl życia testu dla wszystkich testów można zmienić dodając do pliku konfiguracyjnego junit-platform.properties zmienną junit.jupiter.testinstance.lifecycle.default = per_class lub za pomocą zmiennj systemowej o tej samej nazwie.

Kolejnym krokiem będzie modyfikacja metody testowej, tak aby wykorzystywała parametr repetitionInfo w celu dynamicznego tworzenia nazwy zadania na liście:

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
@RepeatedTest(3)
@DisplayName("Creates, renames and completes Todo with given name")
void managesTodo(RepetitionInfo repetitionInfo) {

var todo = MessageFormat.format(
"Todo {0} of {1}", repetitionInfo.getCurrentRepetition(), repetitionInfo.getTotalRepetitions()
);
var todoCount = repetitionInfo.getCurrentRepetition();

// creates the task
page.createTodo(todo);
assertTrue(page.todoExists(todo));
assertEquals(todoCount, page.getTodoCount());

// rename the task
var newTodoName = todo + "'";
page.renameTodo(todo, newTodoName);
assertTrue(page.todoExists(newTodoName));

// complete the task
page.completeTodo(newTodoName);
assertEquals(0, page.getTodosLeft());

// switch to active tasks
page.showActive();
assertEquals(0, page.getTodoCount());

// switch to completed tasks
page.showCompleted();
assertEquals(todoCount, page.getTodoCount());

// switch to all tasks
page.showAll();
assertEquals(todoCount, page.getTodoCount());
}

Warto zauważyć, że po uruchomieniu powyższego testu zostaną utworzone trzy zadania i wszystkie zostaną oznaczone jako zakończone, ale żadne z nich nie zostanie usunięte. Wynika to z faktu, że sterownik, jak również obiekt strony, tworzone są tylko raz - przed uruchomieniem wszystkich testów.

W celu usunięcia zadań po testach moglibyśmy wykorzystać metodę @AfterEach lub @AfterAll (w zależności od potrzeb). Możemy też utworzyć test usuwający wszystkie zakończone zadania, który będzie działał następująco:

  • Uruchomi się tylko, gdy na liście istnieją zadania (assumeTrue())
  • Uruchomi się dokładnie po wykonaniu testów manageTodo()

Implementująca te założenia metoda testowa removeAllCompletedTodos() została dodana jako ostatnia metoda do klasy testowej:

1
2
3
4
5
6
7
8
9
10
@Test
@DisplayName("Removes all todos (one by one) created earlier")
void removeAllCompletedTodos() {
assumeTrue(page.getTodoCount() > 0);

page.showCompleted();

page.getTodos().forEach(t -> page.removeTodo(t));
assertEquals(0, page.getTodoCount());
}

Po uruchomieniu testów okazuje się jednak, że nowoutworzona metoda testowa nie została wykonana:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./gradlew test --tests ManageTodoTest

> Task :test

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > removeAllCompletedTodos() SKIPPED

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > managesTodo(RepetitionInfo)[1] PASSED

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > managesTodo(RepetitionInfo)[2] PASSED

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > managesTodo(RepetitionInfo)[3] PASSED

BUILD SUCCESSFUL in 6s

Metoda removeAllCompletedTodos() została wybrana do uruchomienia jako pierwsza, ale jej wykonanie zostało pominięte, ponieważ na liście nie było wówczas żadnego zadania. Stało się tak, ponieważ kolejność wykonywania testów w JUnit 5 jest ustalana przez framework i nie wynika z kolejności deklaracji metod testowych w klasie testowej.

Domyślna kolejność wykonywania testów między buildami w JUnit 5 jest powatrzalna, zatem deterministyczna, jednak algorytm celowo jest nieoczywisty, o czy wspominają autorzy biblioteki.

Kolejność wykonywania metod testowych (@TestMethodOrder)

Kolejność wykonywania metod testowych w klasie testowej można określić samemu, oznaczając klasę adnotacją @TestMethodOrder, podając jako argument typ sortowania metod. W naszym przykładzie możemy użyć sortowania za pomocą adnotacji @Order:

Więcej o kolejności wykonywania metod testowych w JUnit 5 dowiesz się z mojego artykułu na blogu codeleak.pl: Test Execution Order in JUnit 5.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@DisplayName("Repeated test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ManageTodoTest {



@Order(1)
@RepeatedTest(3)
@DisplayName("Creates, renames and completes Todo with given name")
void managesTodo(RepetitionInfo repetitionInfo) {

}

@Order(2)
@Test
@DisplayName("Removes all todos created earlier")
void removeAllCompletedTodos() {

}
}

Dzięki powyższemu rozwiązaniu mamy gwarancję, że kolejność wykonywania metod testowych będzie zgodna z oczekiwaniami.

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./gradlew test --tests ManageTodoTest

> Task :test

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > managesTodo(RepetitionInfo)[1] PASSED

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > managesTodo(RepetitionInfo)[2] PASSED

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > managesTodo(RepetitionInfo)[3] PASSED

pl.qalabs.blog.junit5.selenium.todomvc.tests.ManageTodoTest > removeAllCompletedTodos() PASSED

BUILD SUCCESSFUL in 6s

Warto pamiętać, że dobre praktyki tworzenia testów automatycznych wskazują na konieczność zachowania niezależności metod testowych - bez względu na kolejność uruchomienia testy powinny działać zawsze tak samo. Nie mniej, są sytuacje, w których kontretna kolejność może mieć swoje uzasadnienie, a decydując się na takie rozwiązanie, zawsze należy rozważyć za i przeciw.

Podsumowanie

Tworzenie testów powtarzanych sprowadza się do użycia adnotacji @RepeatedTest. Tego typu testy mogą być przydatne na przykład, gdy chcemy zweryfikować, czy dla tych samych parametrów wejściowych system zwraca taki sam rezultat niezależnie od ilości wywołań. Mechanizm tworzenie testów powtarzanych w połączeniu z innymi mechanizmami biblioteki JUnit 5 pozwolił na stworzenie prostego testu parametryzowanego.

Szerzej o tworzeniu testów parametryzowanych przeczytasz w kolejnym artykule.

Repozytorium Git projektu

Kod źródłowy opracowany w artykule znajduje się w repozytorium Git: https://gitlab.com/qalabs/blog/junit5-selenium-todomvc-example w pakiecie pl.qalabs.blog.junit5.selenium.todomvc, w branchu 6-repeated-tests.

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.