Testes E2E para Aplicações Web Spring: Renderizando um Único Item

Depois de concluirmos esta aula, nós:

  • Saberemos como autenticar um usuário (login) antes que um método de teste End-to-End seja executado.
  • Saberemos escrever testes End-to-End expressivos que utilizam uma hierarquia de Page Objects.
  • Entenderemos por que e como fazer o logout do usuário (logUserOut) após a execução de cada método de teste.

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

Durante esta aula, escreveremos testes End-to-End para o método de controller que renderiza a página de visualização dos detalhes de uma única tarefa (view task page). Essa página atende aos seguintes requisitos de negócio:

  • A página de visualização de tarefa é renderizada quando um usuário autenticado envia uma requisição GET para o caminho /task/{taskId}.
  • Esta página deve exibir o título (#task-title) e a descrição (#task-description) da tarefa.
  • Se a tarefa estiver atribuída a alguém, a página deve exibir o nome do responsável (#assignee-name).
  • A página deve exibir o horário de criação (#creation-time) e o nome da pessoa que criou a tarefa exibida (#creator-name).
  • A página deve exibir o horário da última modificação (#modification-time) e o nome da pessoa que fez a última alteração na tarefa (#modifier-name).
  • Se a tarefa estiver fechada (CLOSED), a página deve exibir o container #closed-task-fields contendo o horário de fechamento (#closing-time) e o nome da pessoa que fechou a tarefa (#closer-name).
  • A página deve exibir a seção de links de ação (#task-action-links) contendo os links para excluir a tarefa (#delete-task-link) e para abrir a página de edição da tarefa (#edit-task-link / #update-task-link).

O template HTML/JSP da nossa página (view.jsp) possui a seguinte estrutura:

 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
<!DOCTYPE html>
<html>
<head>
    <title></title>
</head>
<body>
<h1 id="task-title">${task.title}</h1>
<div class="panel panel-default">
    <div class="panel-body">
        <div id="task-description">${task.description}</div>
    </div>
</div>
<div id="lifecycle-fields" class="container-fluid">
    <!-- Renderizado apenas se task.assignee != null -->
    <div class="row">
        <div class="col-md-3">
            Assignee: <span id="assignee-name">${task.assignee.name}</span>
        </div>
    </div>

    <div class="row">
        <div class="col-md-3">
            Created by <span id="creator-name">${task.creator.name}</span>
            at <span id="creation-time">${task.creationTime}</span>
        </div>
        <div class="col-md-3">
            Modified by <span id="modifier-name">${task.modifier.name}</span>
            at <span id="modification-time">${task.modificationTime}</span>
        </div>
        <!-- Renderizado apenas se task.status == 'CLOSED' -->
        <div id="closed-task-fields" class="col-md-3">
            Closed by <span id="closer-name">${task.closer.name}</span>
            at <span id="closing-time">${task.modificationTime}</span>
        </div>
    </div>
    <div id="task-action-links" class="row">
        <div class="col-md-1">
            <a id="delete-task-link" href="/task/${task.id}/delete">Delete</a>
        </div>
        <div class="col-md-1">
            <a id="update-task-link" href="/task/${task.id}/update">Edit</a>
        </div>
    </div>
</div>
</body>
</html>

Vamos avançar e criar os cinco Page Objects necessários para modelar essa página e suas seções.


2. Criando os Page Objects Necessários

Antes de podermos escrever nossos testes End-to-End para a funcionalidade de visualização de tarefa, precisamos criar cinco Page Objects. Vamos examiná-los um por um:

1. O Page Object NavigationBar

A classe NavigationBar é um Page Object que representa a barra de navegação superior visível em todas as páginas da nossa aplicação web. Ela possui um método chamado logUserOut(), que encerra a sessão do usuário atual submetendo o formulário #logout-form e retorna uma nova instância de LoginPage. Nossos testes End-to-End invocarão esse método no @AfterEach após cada teste!

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

import com.testwithspring.intermediate.web.login.LoginPage
import org.openqa.selenium.By
import org.openqa.selenium.WebDriver

class NavigationBar(
    private val browser: WebDriver
) {
    fun logUserOut(): LoginPage {
        if (browser.findElements(By.id("logout-form")).isEmpty()) {
            throw RuntimeException("O botão de logout (#logout-form) não está visível na página.")
        }

        browser.findElement(By.id("logout-form")).submit()
        return LoginPage(browser)
    }
}

2. O Page Object TaskPage

A classe TaskPage é o Page Object principal que representa a página de visualização de uma tarefa (/task/{taskId}). Observe como ela aplica a 1ª e a 2ª Boas Práticas que aprendemos na Aula 03, delegando subseções da página para Page Objects especializados:

  • Ela fornece dois métodos (getTaskActions() e getTaskLifeCycleFields()) que retornam outros Page Objects, permitindo acessar as seções #task-action-links e #lifecycle-fields da página.
  • Ela fornece dois métodos (getTaskTitle() e getTaskDescription()) que retornam o título e a descrição da tarefa exibida como String.
  • O método open() abre a página de visualização da tarefa diretamente.
  • O método isOpen() retorna true se a página de visualização da tarefa estiver aberta e false caso contrário.
 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
package com.testwithspring.intermediate.web.task

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

internal class TaskPage(
    private val browser: WebDriver,
    private val taskId: Long?
) {
    private val pageUrl: String = WebDriverUrlBuilder.buildFromPath("/task/%d", taskId)

    fun getTaskActions(): TaskActions {
        return TaskActions(
            browser = browser,
            rootElement = browser.findElement(By.id("task-action-links")),
            taskId = taskId
        )
    }

    fun getTaskLifeCycleFields(): TaskLifeCycleFields {
        return TaskLifeCycleFields(
            rootElement = browser.findElement(By.id("lifecycle-fields"))
        )
    }

    fun getTaskDescription(): String {
        return browser.findElement(By.id("task-description")).text
    }

    fun getTaskTitle(): String {
        return browser.findElement(By.id("task-title")).text
    }

    fun getPageUrl(): String = pageUrl

    fun open(): TaskPage {
        browser.get(pageUrl)
        return TaskPage(browser, taskId)
    }

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

3. O Page Object de Seção TaskLifeCycleFields

A classe TaskLifeCycleFields é um Page Object de fragmento de página que encapsula o elemento <div id="lifecycle-fields"> (rootElement) e nos permite consultar as informações de ciclo de vida e auditoria da tarefa exibida (criador, modificador, responsável e quem fechou a tarefa, além de verificar a visibilidade condicional dos campos):

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

import org.openqa.selenium.By
import org.openqa.selenium.WebElement

internal class TaskLifeCycleFields(
    private val rootElement: WebElement
) {
    fun getAssigneeName(): String {
        return rootElement.findElement(By.id("assignee-name")).text
    }

    fun getCloserName(): String {
        return rootElement.findElement(By.id("closer-name")).text
    }

    fun getCreatorName(): String {
        return rootElement.findElement(By.id("creator-name")).text
    }

    fun getModifierName(): String {
        return rootElement.findElement(By.id("modifier-name")).text
    }

    fun isAssigneeNameVisible(): Boolean {
        return rootElement.findElements(By.id("assignee-name")).size == 1
    }

    fun areClosedTaskFieldsVisible(): Boolean {
        return rootElement.findElements(By.id("closed-task-fields")).size == 1
    }
}

Por que TaskLifeCycleFields busca tudo a partir de rootElement em vez de browser? Porque rootElement já aponta para <div id="lifecycle-fields">. Ao invocar rootElement.findElement(...), restringimos a busca apenas aos elementos filhos daquela seção específica do DOM! Além disso, observe que essa classe não expõe os timestamps (#creation-time, #modification-time) quando os scripts SQL utilizam now() dinâmico no banco, focando nos nomes determinísticos dos usuários de auditoria.

4. O Page Object de Seção TaskActions

A classe TaskActions é um Page Object que encapsula o container <div id="task-action-links"> e nos permite clicar nos dois links de ação da página de detalhes da tarefa:

  • O método deleteTask() clica no link #delete-task-link, aguarda com WebDriverWait até que o navegador seja redirecionado para a URL de TaskListPage e retorna a nova TaskListPage.
  • O método updateTask() clica no link #update-task-link, aguarda com WebDriverWait até que a página de edição UpdateTaskPage seja carregada e retorna a nova UpdateTaskPage.
 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
package com.testwithspring.intermediate.web.task

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 TaskActions(
    private val browser: WebDriver,
    private val rootElement: WebElement,
    private val taskId: Long?
) {
    fun deleteTask(): TaskListPage {
        val deleteLink = rootElement.findElement(By.id("delete-task-link"))
        deleteLink.click()

        val targetPage = TaskListPage(browser)

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

        return targetPage
    }

    fun updateTask(): UpdateTaskPage {
        val updateTaskLink = rootElement.findElement(By.id("update-task-link"))
        updateTaskLink.click()

        val targetPage = UpdateTaskPage(browser, taskId)

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

        return targetPage
    }
}

5. O Page Object UpdateTaskPage

Por fim, a classe UpdateTaskPage é um Page Object que representa a página de atualização da tarefa (/task/{taskId}/update). Ela fornece os métodos getPageUrl() (usado pelo WebDriverWait dentro de TaskActions.updateTask()) e isOpen():

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

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

internal class UpdateTaskPage(
    private val browser: WebDriver,
    private val taskId: Long?
) {
    private val pageUrl: String = WebDriverUrlBuilder.buildFromPath("/task/%d/update", taskId)

    fun getPageUrl(): String = pageUrl

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

A seguir, veremos como utilizar esses cinco Page Objects para escrever nossos testes End-to-End de visualização de tarefa!


3. Escrevendo Testes End-to-End para a Visualização de Tarefa

Podemos construir nossa classe de teste End-to-End seguindo estes 5 passos:

Passo 1: Criar a Classe de Teste e Configurar o WebDriver

Primeiro, criamos a classe de teste RenderViewTaskPageTest e configuramos o navegador com @SeleniumTest:

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

import com.testwithspring.intermediate.SeleniumTest
import com.testwithspring.intermediate.SeleniumWebDriver
import org.junit.jupiter.api.Tag
import org.openqa.selenium.WebDriver
import org.openqa.selenium.chrome.ChromeDriver

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

    @SeleniumWebDriver
    lateinit var browser: WebDriver
}

Passo 2: Adicionar o Campo taskPage à Classe de Teste

Segundo, adicionamos uma propriedade private lateinit var taskPage: TaskPage à nossa classe de teste. Nossos métodos de teste utilizarão esse campo quando abrirem a página de visualização da tarefa:

1
2
3
4
5
6
7
8
9
@SeleniumTest(driver = ChromeDriver::class)
@Tag("endToEndTest")
class RenderViewTaskPageTest {

    @SeleniumWebDriver
    lateinit var browser: WebDriver

    private lateinit var taskPage: TaskPage
}

Passo 3: Implementar o Método @BeforeEach e Autenticar o Usuário

Terceiro, adicionamos um método de setup anotado com @BeforeEach que executa duas ações:

  1. Autentica o usuário antes da execução do método de teste (logUserIn()): Precisamos fazer isso porque nossa aplicação web exige autenticação e não permite que usuários anônimos acessem as tarefas armazenadas no banco de dados.
  2. Cria um novo objeto TaskPage para o ID da tarefa conhecida (EndToEndTestTasks.WriteExampleApp.ID) e armazena a referência no campo taskPage.
 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
import com.testwithspring.intermediate.EndToEndTestTasks
import com.testwithspring.intermediate.EndToEndTestUsers.JohnDoe
import com.testwithspring.intermediate.web.login.LoginPage
import org.junit.jupiter.api.BeforeEach

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

    @SeleniumWebDriver
    lateinit var browser: WebDriver

    private lateinit var taskPage: TaskPage

    @BeforeEach
    fun configureTestCases() {
        logUserIn()
        taskPage = TaskPage(
            browser = browser,
            taskId = EndToEndTestTasks.WriteExampleApp.ID
        )
    }

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

Observe que nossos testes End-to-End utilizam objetos de constantes (EndToEndTestUsers e EndToEndTestTasks) sincronizados com os dados inseridos pelos nossos scripts SQL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.testwithspring.intermediate

object EndToEndTestUsers {

    object JohnDoe {
        const val EMAIL_ADDRESS = "john.doe@gmail.com"
        const val NAME = "John Doe"
        const val PASSWORD = "user"
    }
}

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"
        }
    }
}

Passo 4: Fazer Logout do Usuário no Método @AfterEach

Quarto, precisamos garantir que o usuário logado seja desconectado (logUserOut()) após a execução de cada método de teste. Esse passo é fundamental (especialmente quando a mesma sessão de navegador é reutilizada entre métodos de teste na mesma classe, como no SeleniumTestExecutionListener da Aula 03 do Tópico 06), pois garante que cada teste comece sem sessão ativa e autentique explicitamente o usuário correto!

1
2
3
4
    @AfterEach
    fun logUserOut() {
        NavigationBar(browser).logUserOut()
    }

Passo 5: Escrever os Quatro Métodos de Teste End-to-End

Quinto, escrevemos quatro testes End-to-End utilizando exclusivamente a DSL dos nossos Page Objects:

  1. Verificar se conseguimos abrir a página de visualização da tarefa (shown.isOpen()).
  2. Garantir que a página de visualização exibe o título correto da tarefa (shown.getTaskTitle()).
  3. Verificar se a subseção de ciclo de vida exibe o nome da pessoa que criou a tarefa (lifeCycleFields.getCreatorName()).
  4. Garantir que clicar no link de edição abre a página de atualização da tarefa (targetPage.isOpen()).

Veja o código completo da nossa classe RenderViewTaskPageTest:

 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
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 RenderViewTaskPageTest {

    @SeleniumWebDriver
    lateinit var browser: WebDriver

    private lateinit var taskPage: TaskPage

    @BeforeEach
    fun configureTestCases() {
        logUserIn()
        taskPage = TaskPage(
            browser = browser,
            taskId = EndToEndTestTasks.WriteExampleApp.ID
        )
    }

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

    @Test
    fun `deve abrir a pagina de visualizacao da tarefa`() {
        val shown = taskPage.open()
        assertThat(shown.isOpen()).isTrue()
    }

    @Test
    fun `deve exibir o titulo da tarefa visualizada`() {
        val shown = taskPage.open()
        assertThat(shown.getTaskTitle())
            .isEqualTo(EndToEndTestTasks.WriteExampleApp.TITLE)
    }

    @Test
    fun `deve exibir o nome do criador da tarefa visualizada`() {
        val shown = taskPage.open()
        val lifeCycleFields = shown.getTaskLifeCycleFields()

        assertThat(lifeCycleFields.getCreatorName())
            .isEqualTo(EndToEndTestTasks.WriteExampleApp.Creator.NAME)
    }

    @Test
    fun `deve permitir navegar para a pagina de atualizacao da tarefa`() {
        val shown = taskPage.open()
        val actions = shown.getTaskActions()

        val targetPage = actions.updateTask()
        assertThat(targetPage.isOpen()).isTrue()
    }

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

Resumo

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

  • Se a nossa aplicação web não permite que usuários anônimos acessem a funcionalidade testada, devemos autenticar o usuário (LoginPage(browser).open().login(...)) dentro do método @BeforeEach.
  • Ao encapsularmos toda a API do WebDriver dentro de Page Objects (TaskPage, TaskLifeCycleFields, TaskActions, UpdateTaskPage), nossos métodos @Test ficam extremamente curtos e expressam diretamente as regras de negócio da funcionalidade.
  • Devemos fazer logout do usuário atual no método @AfterEach (NavigationBar(browser).logUserOut()) para garantir que cada teste End-to-End subsequente seja executado com uma sessão limpa e com o usuário correto.