JUnit 5 i Selenium - Odpowiedzialność metod testowych

W artukule JUnit 5 i Selenium - Wprowadzenie do projektu automatycznych testów aplikacji internetowej rozpocząłem tworzenie projektu testów automatycznych dla aplikacji TodoMVC. Mimo że aplikacja jest bardzo prosta, stworzyłem sporo kodu, którego utrzymanie i modyfikacja to nie lada wyzwanie. W tym artykule spróbuję usprawnić projekt poprzez eliminację powtarzającego się kodu oraz prawidłowe określenie odpowiedzialności metod testowych.

O serii

Czytasz część 2 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 1-getting-started. Klasa testowa, która zostanie zmodyfikowana w tym artykule, to TodoMvcTests, do jej kodu można przejść bezpośrednio po kliknięciu w link pl.qalabs.blog.junit5.selenium.todomvc.TodoMvcTests.

Właściwa odpowiedzialność metod testowych

Jeśli dobrze przyjrzeć się implementacji testów, to można w niej zauważyć dwie części: pierwszą, związaną z niskopoziomową manipulacją interfejsem użytkownika, wymaganą przez testy (wyszukiwanie elementów, wypełnianie pól, klikanie - mechanika strony) oraz drugą, czyli właściwą logikę testu (tworzenie todo, usuwanie todo - funkcjonalność aplikacji). Do tej pory metody testowe nie posiadały wyraźnego podziału odpowiedzialności. Sprawiało to, że testy były trudniejsze w analizie, a przede wszystkim w utrzymaniu.

Czytając poniższy kod musimy nie tylko zrozumieć, jaka funkcjonalność jest wykorzystywana podczas testu, ale również w jaki sposób musimy zmodyfikować elementy interfejsu w celu wywołania tej funkcjonalności:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
void createsTodo() {
// utworzenie todo
WebElement todoInput = driver.findElement(By.className("new-todo"));
todoInput.sendKeys("My Todo 1");
todoInput.sendKeys(Keys.ENTER);

// weryfikacja liczby pozostałych todo
WebElement todoCount = driver.findElement(By.cssSelector(".todo-count > strong"));
assertEquals(1, Integer.parseInt(todoCount.getText()));

// weryfikacja występowania todo na liście
List<WebElement> todoList = driver.findElements(By.cssSelector(".todo-list li"));
assertTrue(todoList.stream().anyMatch(el -> "My Todo 1".equals(el.getText())));
}

Jeżeli tylko rozdzielimy te odpowiedzialności, zastępując fragmenty oznaczone komentarzami wywołaniami nowych metod, które zawierać będą logikę związaną z manipulacją zawartością strony, otrzymamy dużo bardziej czytelny kod:

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
@Test
@DisplayName("Creates Todo with given title")
void createsTodo() {
String todoName = "Some name";
createTodo(todoName); // funkcjonalność testowanej aplikacji

assertEquals(1, getTodosLeft()); // weryfikacja
assertTrue(todoExists(todoName)); // weryfikacja
}

private void createTodo(String todoName) {
// niskopoziomowa manipulacja interfejsem użytkownika
WebElement todoInput = driver.findElement(By.className("new-todo"));
todoInput.sendKeys(todoName);
todoInput.sendKeys(Keys.ENTER);
}

private int getTodosLeft() {
// niskopoziomowa manipulacja interfejsem użytkownika
return Integer.parseInt(
driver.findElement(By.cssSelector(".todo-count > strong")).getText()
);
}

private boolean todoExists(String todoName) {
// niskopoziomowa manipulacja interfejsem użytkownika
List<WebElement> todos = driver.findElements(By.cssSelector(".todo-list li"));
return todos.stream().anyMatch(el -> todoName.equals(el.getText()));
}

Metody prywatne, wykorzystywane w teście, dostarczają programiście wysokopoziomowe API, ukrywające szczegóły implementacyjne, służące obsłudze aplikacji z poziomu użytkownika. Z perspektywy testu, użytkownik tworzy todo, użytkownik usuwa todo, użytkownik widzi, ile todo pozostało do zrobienia itd. Szczegóły technicznie, czyli w jaki sposób todo jest tworzone lub usuwane, metody testowej nie interesuje. Można zatem przyjąć, że test należy traktować jak użytkownika aplikacji, ukrywając przed nim szczegóły implementacji danej funkcjonalności.

Takie podejście znacznie poprawia czytelność kodu, sprawiając dodatkowo, że metody testowe są pewnego rodzaju dokumentacją funkcjonalności testowanej aplikacji. Warto również zwrócić uwagę na “niewyciekanie” WebDriver API do metod testowych - metody testowe właściwie nic o nim nie wiedzą.

Refaktoryzacja pozostałych metod testowych

Postępując zgodnie z powyższym przykładem, wykorzystując podstawowe techniki refaktoryzacji, takie jak extract method czy extract variable, możemy usprawnić kod dla całej klasy testowej. Dodatkowo też pozbywamy się problemu duplikacji kodu, gdyż poszczególne akcje użytkownika uzyskują swoje metody (API), przez co zwiększa się możliwość ich ponownego użycia w pozostałych metodach testowych. Takie podejście eliminuje wiele problemów z późniejszym utrzymaniem i rozszerzaniem testów. Na przykład jeżeli zmieni się lokator pola tekstowego w formularzu dodawania todo, zmiana w kodzie nastąpi tylko w jednym miejscu, bez wpływu na istniejące już metody testowe.

W procesie refaktoryzacji warto korzystać ze wsparcia, jakie daje IntelliJ. W trakcie refaktoryzacji kodu podczas tworzenia tego artykułu korzystałem z głównie z:

Korzystając z powyższych technik, kod metod testowych można usprawnić aż do uzyskania następującego efektu:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@Test
@DisplayName("Edits inline double-clicked Todo")
void editsTodo() {
String todoName = "My Todo 1";
String newTodoName = "New Todo 1";
createTodo(todoName);

renameTodo(todoName, newTodoName);
assertTrue(todoExists(newTodoName));
}

@Test
@DisplayName("Removes selected Todo")
void removesTodo() {
String todoName = "My Todo 1";
createTodo(todoName);

removeTodo(todoName);
assertFalse(todoExists(todoName));
}

@Test
@DisplayName("Toggles selected Todo as completed")
void togglesTodoCompleted() {
String todoName = "My Todo 1";
String anotherTodoName = "My Todo 2";
createTodos(todoName, anotherTodoName);

completeTodo(todoName);
assertEquals(1, getTodosLeft());

showCompleted();
assertEquals(1, getTodoCount());

showActive();
assertEquals(1, getTodoCount());
}

@Test
@DisplayName("Toggles all Todos as completed")
void togglesAllTodosCompleted() {
String todoName = "My Todo 1";
String anotherTodoName = "My Todo 2";
createTodos(todoName, anotherTodoName);

completeAllTodos();
assertEquals(0, getTodosLeft());

showCompleted();
assertEquals(2, getTodoCount());

showActive();
assertEquals(0, getTodoCount());
}

@Test
@DisplayName("Clears all completed Todos")
void clearsCompletedTodos() {
String todoName = "My Todo 1";
String anotherTodoName = "My Todo 2";
createTodos(todoName, anotherTodoName);

completeAllTodos();
createTodo("My Todo 3");

clearCompleted();
assertEquals(1, getTodosLeft());

showCompleted();
assertEquals(0, getTodoCount());

showActive();
assertEquals(1, getTodoCount());
}

Utworzone API może być z łatwością wykorzystywane do tworzenia kolejnych metod testowych. Na przykład:

1
2
3
4
5
6
7
8
9
10
11
@Test
@DisplayName("Creates Todos all with the same name")
void createsTodosWithSameName() {
String todoName = "My Todo 1";

createTodos(todoName, todoName, todoName);
assertEquals(3, getTodosLeft());

showActive();
assertEquals(3, getTodoCount());
}

Poprawny podział odpowiedzialności oraz usunięcie duplikacji poprzez utworzenie metod prywatnych pozytywnie wpłynęło na jakość i czytelność kodu metod testowych. Usprawnianie można kontunuować aż do uzyskania całkowitej eliminacji powtórzeń, również na poziomie użycia WebDriver API w nowoutworzonych metodach prywatnych. To spowoduje, że pojawi się dodatkowa warstwa odpowiedzialna za powtarzalne operacje z użyciem WebDriver API:

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

// warstwa 1 - logika testu

@Test
@DisplayName("Creates Todo with given name")
void createsTodo() {
String todoName = "My Todo 1";
createTodo(todoName);
assertEquals(1, getTodosLeft());
assertTrue(todoExists(todoName));
}

// warstwa 2 - manipulacja interfejsem użytkownika

private void createTodo(String todoName) {
type(By.className("new-todo"), todoName + Keys.ENTER);
}

// warstwa 3 - opakowanie dostępu do `WebDriver API`

private WebElement find(By by) {
return find(by, driver);
}

private WebElement find(By by, SearchContext searchContext) {
return searchContext.findElement(by);
}

private void type(By by, CharSequence charSequence) {
type(find(by), charSequence);
}

private void type(WebElement element, CharSequence value) {
element.sendKeys(value);
}

Dalsze zmiany mogą doprowadzić do niemal całkowitego wyeliminowania duplikacji. Jako ciekawostkę przedstawię wynik analizy powtórzeń w IntelliJ przed i po refaktoryzacji:

  • Przed:
  • Po:

Dalsze kroki

Poprawny podział odpowiedzialności oraz usunięcie duplikacji poprzez utworzenie metod prywatnych pozytywnie wpłynęło na jakość i czytelność kodu. Kolejnym krokiem będzie wydzielenie odpowiednich warstw do oddzielnych klas w celu zwiększenie możliwości ponownego użycia nowego API również w innych klasach testowych.

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 branchu 2-tests-responsibility.

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.