Testes E2E para Aplicações Web Spring: Renderizando uma Lista

Depois de concluirmos esta aula, nós:

  • Saberemos como criar Page Objects para uma página que renderiza uma lista de itens (TaskListPage e TaskListItem).
  • Entenderemos como embutir metadados semânticos no HTML (como IDs e status em atributos id e class) quando uma informação exigida pelo teste não está visível em texto puro.
  • Compreenderemos os desafios que enfrentamos quando inicializamos o banco de dados apenas uma vez no startup da aplicação versus antes de cada teste, e saberemos escrever testes End-to-End resilientes para páginas de listagem.

1. Introdução ao Sistema Sob Teste (SUT): A Página de Listagem de Tarefas

Durante esta aula, escreveremos testes End-to-End para a funcionalidade que renderiza a página de listagem de tarefas (task list page). Essa página é renderizada quando um usuário autenticado envia uma requisição GET para o caminho raiz /, e atende aos seguintes requisitos de negócio:

  • Deve exibir a quantidade de tarefas encontradas no banco de dados.
  • Deve exibir a tabela de tarefas (#task-list) encontradas no banco de dados.
  • Deve exibir o título (.task-list-item-title) e o status traduzido/localizado (.task-list-item-status) de cada tarefa.
  • O título de cada tarefa deve ser um link clicável que abre a página de visualização daquela tarefa (/task/{taskId}).

Entretanto, essa página possui um desafio comum em interfaces reais: ela não exibe diretamente em texto puro todas as informações técnicas que nossos testes End-to-End precisam verificar (como o id numérico da tarefa no banco ou a constante do enum TaskStatus independente do idioma da tradução).

Por isso, adicionamos duas informações estruturadas aos atributos HTML de cada linha da tabela:

  1. O id da tarefa exibida é concatenado ao atributo id do elemento <tr> que envolve os dados daquela tarefa: <tr id="task-list-item-${task.id}" class="task-list-item">.
  2. O nome do enum status da tarefa (OPEN, IN_PROGRESS, CLOSED) é concatenado ao atributo class do elemento <td> que exibe o status traduzido da tarefa: <td class="task-list-item-status task-status-${task.status}">.

O código-fonte do template HTML/JSP (list.jsp) fica assim:

 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
<!DOCTYPE html>
<html>
<head>
    <title>Task List</title>
</head>
<body>
<h1>Task List</h1>
<div>
    Found ${tasks.size()} tasks.
</div>
<table id="task-list" class="table table-striped">
    <thead>
    <tr>
        <th>Title</th>
        <th>Status</th>
    </tr>
    </thead>
    <tbody>
    <!-- Para cada tarefa na lista tasks: -->
    <tr id="task-list-item-${task.id}" class="task-list-item">
        <td>
            <a class="task-list-item-title" href="/task/${task.id}">
                ${task.title}
            </a>
        </td>
        <td class="task-list-item-status task-status-${task.status}">
            ${task.status.localizedLabel}
        </td>
    </tr>
    </tbody>
</table>
</body>
</html>

Vamos avançar e criar os dois Page Objects necessários para interagir com essa tabela.


2. Criando os Page Objects Necessários (TaskListPage e TaskListItem)

Antes de escrevermos nossos testes End-to-End para a listagem de tarefas, precisamos criar dois Page Objects que trabalham em conjunto:

1. O Page Object TaskListPage

A classe TaskListPage representa a página de listagem de tarefas como um todo. Ela possui quatro métodos:

  • getListItems(): Localiza todos os elementos com a classe .task-list-item no documento HTML, transforma cada WebElement encontrado em um objeto TaskListItem(browser, element) e retorna a lista List<TaskListItem>.
  • getPageUrl(): Retorna a URL da página de listagem de tarefas.
  • isOpen(): Retorna true se a página de listagem de tarefas estiver aberta no navegador e false caso contrário.
  • open(): Abre a página de listagem de tarefas e retorna uma nova instância de TaskListPage.

O código-fonte da classe TaskListPage em Kotlin fica assim:

 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
package com.testwithspring.intermediate.web.task

import com.testwithspring.intermediate.WebDriverUrlBuilder
import org.openqa.selenium.By
import org.openqa.selenium.WebDriver

class TaskListPage(
    private val browser: WebDriver
) {
    private val pageUrl: String = WebDriverUrlBuilder.buildFromPath("/")

    internal fun getListItems(): List<TaskListItem> {
        return browser.findElements(By.className("task-list-item"))
            .map { element -> TaskListItem(browser, element) }
    }

    internal fun getPageUrl(): String = pageUrl

    fun isOpen(): Boolean {
        return browser.currentUrl == pageUrl
    }

    fun open(): TaskListPage {
        browser.get(pageUrl)
        return TaskListPage(browser)
    }
}

2. O Page Object de Item de Lista TaskListItem

A classe TaskListItem é um Page Object que representa uma única linha (<tr>) da tabela de tarefas exibida na TaskListPage. Criar um Page Object dedicado para um item de lista é uma excelente prática de design porque mantém as responsabilidades bem separadas e evita que TaskListPage fique poluída com métodos de extração de colunas.

A classe TaskListItem possui quatro métodos:

  • getId(): Extrai o id numérico da tarefa a partir do atributo id do elemento <tr> ("task-list-item-1" -> 1L).
  • getTitle(): Retorna o texto do link .task-list-item-title dentro daquela linha.
  • getStatus(): Extrai a constante do enum TaskStatus a partir do atributo class do elemento <td> (.task-list-item-status), removendo os prefixos de classe CSS ("task-list-item-status" e "task-status-") e convertendo a string resultante com TaskStatus.valueOf(status). Isso torna o nosso teste 100% imune a mudanças de tradução/i18n na interface!
  • viewTask(): Clica no link .task-list-item-title para abrir a página de detalhes da tarefa, aguarda o redirecionamento com WebDriverWait e retorna um novo objeto TaskPage.

O código-fonte da classe TaskListItem fica assim:

 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
package com.testwithspring.intermediate.web.task

import com.testwithspring.intermediate.task.TaskStatus
import org.openqa.selenium.By
import org.openqa.selenium.WebDriver
import org.openqa.selenium.WebElement
import org.openqa.selenium.support.ui.ExpectedConditions
import org.openqa.selenium.support.ui.WebDriverWait
import java.time.Duration

internal class TaskListItem(
    private val browser: WebDriver,
    private val itemRootElement: WebElement
) {
    fun getId(): Long {
        val elementId = itemRootElement.getAttribute("id")
            ?: throw IllegalStateException("Atributo id não encontrado no item da lista")
        val taskId = elementId.replace("task-list-item-", "")
        return taskId.toLong()
    }

    fun getTitle(): String {
        return itemRootElement.findElement(By.className("task-list-item-title")).text
    }

    fun getStatus(): TaskStatus {
        val statusElement = itemRootElement.findElement(
            By.className("task-list-item-status")
        )

        val status = (statusElement.getAttribute("class") ?: "")
            .replace("task-list-item-status", "")
            .trim()
            .replace("task-status-", "")

        return TaskStatus.valueOf(status)
    }

    fun viewTask(): TaskPage {
        val viewTaskLink = itemRootElement.findElement(
            By.className("task-list-item-title")
        )
        val targetPage = TaskPage(browser, getId())

        viewTaskLink.click()

        val wait = WebDriverWait(browser, Duration.ofSeconds(3))
        wait.until(ExpectedConditions.urlToBe(targetPage.getPageUrl()))

        return targetPage
    }
}

A seguir, vamos descobrir como utilizar TaskListPage e TaskListItem para escrever nossos testes End-to-End da funcionalidade de listagem de tarefas.


3. Escrevendo Testes End-to-End para a Listagem de Tarefas

Podemos escrever nossos testes End-to-End seguindo estes 5 passos:

Passos 1 a 4: Configurar a Classe de Teste, @BeforeEach (Login) e @AfterEach (Logout)

Assim como fizemos na aula anterior:

  1. Criamos a classe RenderTaskListPageTest anotada com @SeleniumTest.
  2. Adicionamos o campo private lateinit var taskListPage: TaskListPage.
  3. No método @BeforeEach, autenticamos o usuário JohnDoe usando LoginPage(browser).open().login(...) e instanciamos TaskListPage(browser).
  4. No método @AfterEach, encerramos a sessão do usuário chamando NavigationBar(browser).logUserOut().

Passo 5: Lidando com o Estado do Banco e Escrevendo os 4 Testes

Se estivermos inicializando nosso banco de dados apenas uma vez quando a aplicação web é iniciada (por exemplo, via Liquibase em uma aplicação Spring clássica, como vimos na Aula 04 do Tópico 06), escrever testes para uma página de listagem exige cuidado extra porque não sabemos a ordem exata em que os testes End-to-End da suíte serão executados:

  • Nosso script SQL inicial insere 3 tarefas (id = 1, id = 2 e id = 3).
  • Porém, nossa suíte E2E também possui um teste que deleta a terceira tarefa (id = 3) e outro teste que cria uma nova tarefa (id = 4), além de um teste que atualiza a segunda tarefa (id = 2).
  • Portanto, se o banco não for resetado a cada teste, sabemos que no momento em que RenderTaskListPageTest rodar:
    1. Existirão pelo menos 2 tarefas no banco de dados (tasks.size >= 2).
    2. A primeira tarefa (id = 1 — WriteExampleApp) nunca é modificada nem excluída por nenhum outro teste, então podemos fazer asserções exatas sobre todos os campos do primeiro item da lista (tasks[0])!

Nota sobre Spring Boot + @Sql: Se você estiver escrevendo testes End-to-End para uma aplicação Spring Boot e utilizando @Sql para resetar e popular o banco antes de cada método de teste (como vimos na Aula 05 do Tópico 06), você não terá essa limitação, pois o banco sempre terá exatamente as 3 tarefas originais no início de cada método @Test!

Vamos analisar os quatro métodos de teste da nossa classe RenderTaskListPageTest:

  1. Verificar se conseguimos abrir a página de listagem de tarefas (shown.isOpen()).
  2. Garantir que a página de listagem exibe pelo menos duas tarefas (assertThat(tasks.size).isGreaterThanOrEqualTo(2)).
  3. Verificar se a página de listagem exibe o id, title e status corretos da primeira tarefa (WriteExampleApp).
  4. Garantir que conseguimos navegar da página de listagem para a página de visualização da tarefa clicando no primeiro item (first.viewTask()).

O código completo da classe RenderTaskListPageTest fica assim:

 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
75
76
package com.testwithspring.intermediate.web.task

import com.testwithspring.intermediate.EndToEndTestTasks
import com.testwithspring.intermediate.EndToEndTestUsers.JohnDoe
import com.testwithspring.intermediate.SeleniumTest
import com.testwithspring.intermediate.SeleniumWebDriver
import com.testwithspring.intermediate.web.NavigationBar
import com.testwithspring.intermediate.web.login.LoginPage
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.openqa.selenium.WebDriver
import org.openqa.selenium.chrome.ChromeDriver

@SeleniumTest(driver = ChromeDriver::class)
@Tag("endToEndTest")
class RenderTaskListPageTest {

    @SeleniumWebDriver
    lateinit var browser: WebDriver

    private lateinit var taskListPage: TaskListPage

    @BeforeEach
    fun configureTestCases() {
        logUserIn()
        taskListPage = TaskListPage(browser)
    }

    private fun logUserIn() {
        val loginPage = LoginPage(browser).open()
        loginPage.login(JohnDoe.EMAIL_ADDRESS, JohnDoe.PASSWORD)
    }

    @Test
    fun `deve abrir a pagina de listagem de tarefas`() {
        val shown = taskListPage.open()
        assertThat(shown.isOpen()).isTrue()
    }

    @Test
    fun `deve exibir pelo menos duas tarefas na lista`() {
        val shown = taskListPage.open()
        val tasks = shown.getListItems()
        assertThat(tasks.size).isGreaterThanOrEqualTo(2)
    }

    @Test
    fun `deve exibir as informacoes corretas da primeira tarefa`() {
        val shown = taskListPage.open()
        val first = shown.getListItems().first()

        assertThat(first.getId())
            .isEqualByComparingTo(EndToEndTestTasks.WriteExampleApp.ID)
        assertThat(first.getTitle())
            .isEqualTo(EndToEndTestTasks.WriteExampleApp.TITLE)
        assertThat(first.getStatus())
            .isEqualTo(EndToEndTestTasks.WriteExampleApp.STATUS)
    }

    @Test
    fun `deve permitir navegar da lista para a pagina de visualizacao da tarefa`() {
        val openedListPage = taskListPage.open()
        val first = openedListPage.getListItems().first()

        val shownTaskPage = first.viewTask()
        assertThat(shownTaskPage.isOpen()).isTrue()
    }

    @AfterEach
    fun logUserOut() {
        NavigationBar(browser).logUserOut()
    }
}

E a constante EndToEndTestTasks.WriteExampleApp utilizada pelas asserções acima possui a seguinte definição:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package com.testwithspring.intermediate

import com.testwithspring.intermediate.task.TaskStatus

object EndToEndTestTasks {

    object WriteExampleApp {
        const val ID = 1L
        const val TITLE = "Write example application"
        val STATUS = TaskStatus.OPEN

        object Creator {
            const val ID = 1L
            const val NAME = "John Doe"
        }
    }
}

Resumo

Esta aula nos ensinou três lições fundamentais:

  • Se uma informação exigida pelos nossos testes End-to-End (como o id da entidade ou a chave não traduzida de um enum) não estiver diretamente visível em texto na interface do usuário, podemos embutir essa informação de forma limpa nos atributos id, class ou data-* do elemento HTML renderizado.
  • É uma excelente prática criar um Page Object dedicado para representar um único item de uma lista (TaskListItem). Isso mantém a classe TaskListPage enxuta e separa claramente as responsabilidades.
  • Se inicializarmos nosso banco de dados apenas uma vez quando a aplicação web é iniciada (sem resetar via @Sql a cada método de teste), devemos escrever asserções que não dependam da ordem de execução de outros testes que inserem, editam ou removem outras linhas da tabela.