Testes E2E para Aplicações Web Spring: Submetendo um Formulário

Depois de concluirmos esta aula, nós:

  • Saberemos como criar Page Objects para uma página que exibe um formulário (CreateTaskPage e TaskForm).
  • Entenderemos os desafios que enfrentamos ao verificar a URL de redirecionamento quando o ID da entidade recém-criada é gerado dinamicamente pelo banco de dados.
  • Saberemos escrever testes End-to-End completos para funcionalidades que submetem um formulário HTML.

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

Durante esta aula, escreveremos testes End-to-End para a funcionalidade que permite aos usuários criar novas tarefas. A página de criação de tarefa (create task page) é renderizada quando um usuário autenticado envia uma requisição GET para o caminho /task/create, e contém um formulário simples (#create-task-form) que permite informar o título (#task-title) e a descrição (#task-description) da nova tarefa.

O código-fonte do nosso template HTML/JSP (create.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
<!DOCTYPE html>
<html>
<head>
    <title>Create a New Task</title>
    <script type="text/javascript" src="/static/js/app/form.js"></script>
</head>
<body>
<h1>Create a New Task</h1>
<div class="panel panel-default">
    <div class="panel-body">
        <form id="create-task-form" action="/task/create" method="POST" role="form">
            <input type="hidden" name="_csrf" value="..."/>
            <div class="form-group">
                <label for="task-title">Title</label>
                <input id="task-title" class="form-control" name="title" type="text"/>
                <span id="error-title" class="help-block"></span>
            </div>
            <div class="form-group">
                <label for="task-description">Description</label>
                <textarea id="task-description" class="form-control" name="description"></textarea>
                <span id="error-description" class="help-block"></span>
            </div>
            <button type="submit" class="btn btn-default">Create Task</button>
        </form>
    </div>
</div>
</body>
</html>

Vamos avançar e criar os Page Objects necessários para interagir com essa página e seu formulário.


2. Criando os Page Objects Necessários (CreateTaskPage e TaskForm)

Antes de escrevermos nossos testes End-to-End para a funcionalidade de criação de tarefas, precisamos criar dois Page Objects e fazer uma pequena adaptação na classe TaskPage:

1. O Page Object CreateTaskPage

A classe CreateTaskPage representa a página de criação de tarefa (/task/create). Ela possui três métodos:

  1. getForm(): Retorna um objeto TaskForm, que é o Page Object que representa o formulário de criação ou atualização de tarefa (#create-task-form).
  2. isOpen(): Retorna true se a página de criação de tarefa estiver aberta e false caso contrário.
  3. open(): Abre a página de criação de tarefa e retorna uma nova instância de CreateTaskPage.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.testwithspring.intermediate.web.task

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

internal class CreateTaskPage(
    private val browser: WebDriver
) {
    private val pageUrl: String = WebDriverUrlBuilder.buildFromPath("/task/create")

    fun getForm(): TaskForm {
        return TaskForm(browser, "create-task-form")
    }

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

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

2. O Page Object de Formulário TaskForm

A classe TaskForm é um Page Object que representa tanto o formulário de criação quanto o formulário de edição de tarefa (bastando passar o formId correspondente no construtor!). Criar um Page Object separado para um formulário é uma excelente prática porque separa as responsabilidades da página das responsabilidades de manipulação de inputs.

A API da classe TaskForm possui cinco métodos:

  1. typeDescription(description: String): Limpa (clear()) qualquer descrição existente na <textarea id="task-description"> e digita o novo valor.
  2. typeTitle(title: String): Limpa (clear()) qualquer título existente no <input id="task-title"> e digita o novo valor.
  3. submitTaskForm(): Submete o formulário (form.submit()) e retorna um novo objeto TaskPage. Se o formulário em questão for o formulário de criação de nova tarefa (que ainda não possui um input oculto #task-id), o método auxiliar getTaskId() retornará null e passará taskId = null para o construtor de TaskPage.
  4. getTaskDescription(): Retorna o valor atual preenchido no campo de descrição.
  5. getTaskTitle(): Retorna o valor atual preenchido no campo de título.

Por que chamamos clear() antes de sendKeys() em typeTitle() e typeDescription()? Porque o método WebElement.sendKeys() apenas concatena os caracteres ao final do texto que já estiver dentro do input! Como TaskForm também é reutilizado no formulário de atualização de tarefas (onde os inputs já vêm preenchidos com o título e a descrição antigos), chamar clear() antes de sendKeys() garante que o valor antigo seja substituído pelo novo!

O código-fonte da classe TaskForm 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
package com.testwithspring.intermediate.web.task

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

internal class TaskForm(
    private val browser: WebDriver,
    formId: String
) {
    private val form: WebElement = browser.findElement(By.id(formId))

    fun typeDescription(description: String) {
        val descriptionTextArea = form.findElement(By.id("task-description"))
        descriptionTextArea.clear()
        descriptionTextArea.sendKeys(description)
    }

    fun typeTitle(title: String) {
        val titleInputField = form.findElement(By.id("task-title"))
        titleInputField.clear()
        titleInputField.sendKeys(title)
    }

    fun submitTaskForm(): TaskPage {
        val taskId = getTaskId()
        form.submit()
        return TaskPage(browser, taskId)
    }

    fun getTaskDescription(): String? {
        return form.findElement(By.id("task-description")).getAttribute("value")
    }

    fun getTaskTitle(): String? {
        return form.findElement(By.id("task-title")).getAttribute("value")
    }

    private fun getTaskId(): Long? {
        val idElements = form.findElements(By.id("task-id"))
        if (idElements.size == 1) {
            val taskId = idElements.first().getAttribute("value")
            return taskId?.toLong()
        }
        return null
    }
}

3. Adaptando TaskPage para Lidar com taskId Desconhecido (isOpenWithUnknownTaskId)

Quando submetemos o formulário de criação de tarefa com dados válidos, nossa aplicação web redireciona o navegador para a página de visualização da tarefa recém-criada (/task/{id}). É por isso que o método submitTaskForm() de TaskForm retorna um novo objeto TaskPage.

Entretanto, se estivermos inicializando nosso banco de dados apenas uma vez no startup da aplicação (e vários testes criarem tarefas), não podemos saber de antemão qual será o id exato gerado pela sequence do banco para a tarefa criada. Isso significa que precisamos passar taskId = null para o construtor de TaskPage quando a tarefa acaba de ser criada!

Para suportar esse cenário com segurança, fazemos duas melhorias na classe TaskPage:

  1. Garantimos que o método open() lance uma exceção (RuntimeException) caso o valor do campo taskId seja null (já que não é possível abrir /task/null diretamente).
  2. Adicionamos o método isOpenWithUnknownTaskId() à classe TaskPage. Esse método remove o prefixo base http://localhost:8080/task/ da URL atual do navegador e verifica se o sufixo restante da URL é um número inteiro válido (o ID gerado pelo banco)!

Veja como fica a classe TaskPage atualizada:

 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
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 getPageUrl(): String = pageUrl

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

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

    fun open(): TaskPage {
        if (taskId == null) {
            throw RuntimeException("Não é possível abrir a página de visualização de tarefa quando taskId é nulo.")
        }

        browser.get(pageUrl)
        return TaskPage(browser, taskId)
    }

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

    fun isOpenWithUnknownTaskId(): Boolean {
        val pageUrlWithoutId = WebDriverUrlBuilder.buildFromPath("/task/")
        val currentUrl = browser.currentUrl ?: return false
        val extractedTaskId = currentUrl.replace(pageUrlWithoutId, "")

        return extractedTaskId.isNotEmpty() && extractedTaskId.all { it.isDigit() }
    }
}

Nota sobre Spring Boot + @Sql: Se estivermos escrevendo testes End-to-End para uma aplicação Spring Boot usando @Sql antes de cada método de teste (onde tasks.sql executa ALTER SEQUENCE tasks_id_seq RESTART WITH 4;), sabemos com certeza que o ID da tarefa criada será 4L. Mesmo assim, o método isOpenWithUnknownTaskId() continua sendo extremamente útil porque desacopla o teste E2E do valor interno da sequence do banco!

Vamos ver agora como escrever os testes End-to-End para a funcionalidade de criação de tarefa utilizando esses Page Objects.


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

Podemos construir a classe de teste SubmitCreateTaskFormWithValidInformationTest seguindo estes 7 passos:

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

Primeiro, criamos a classe de teste SubmitCreateTaskFormWithValidInformationTest e configuramos a injeção do navegador:

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

    @SeleniumWebDriver
    lateinit var browser: WebDriver
}

Passo 2: Declarar as Constantes DESCRIPTION e TITLE

Segundo, adicionamos duas constantes à nossa classe de teste (DESCRIPTION e TITLE) contendo o título e a descrição da nova tarefa que será criada pelo formulário. Declaramos essas constantes diretamente dentro da classe de teste porque elas são exclusivas deste cenário e não são compartilhadas com outras classes:

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

    companion object {
        private const val DESCRIPTION = "This is a new lesson"
        private const val TITLE = "Write an extra lesson"
    }

    @SeleniumWebDriver
    lateinit var browser: WebDriver
}

Passo 3: Adicionar o Campo createTaskPage

Terceiro, adicionamos o campo private lateinit var createTaskPage: CreateTaskPage à nossa classe de teste. Nossos métodos de teste utilizarão esse campo quando submeterem o formulário de criação de tarefa.

Passo 4: Implementar o Método @BeforeEach (Login e Abertura da Página de Criação)

Quarto, implementamos o método @BeforeEach seguindo duas etapas:

  1. Autenticar o usuário JohnDoe antes da execução do método de teste (logUserIn()), pois nossa aplicação web não permite que usuários anônimos insiram novos registros no banco de dados.
  2. Instanciar CreateTaskPage(browser), abrir a página de criação invocando .open() e armazenar o objeto retornado no campo createTaskPage.

Passo 5: Implementar o Método @AfterEach (Logout)

Quinto, garantimos que o usuário logado seja desconectado ao final de cada teste invocando NavigationBar(browser).logUserOut() no método @AfterEach.

Passo 6: Escrever o Método Auxiliar createTask()

Sexto, escrevemos um método privado createTask(): TaskPage que obtém o TaskForm a partir de createTaskPage, digita o TITLE e a DESCRIPTION e submete o formulário. Extrair esse método auxiliar evita repetir o preenchimento do formulário em cada um dos quatro métodos @Test:

1
2
3
4
5
6
    private fun createTask(): TaskPage {
        val createTaskForm = createTaskPage.getForm()
        createTaskForm.typeTitle(TITLE)
        createTaskForm.typeDescription(DESCRIPTION)
        return createTaskForm.submitTaskForm()
    }

Passo 7: Escrever os Quatro Testes End-to-End

Sétimo, escrevemos os quatro testes End-to-End para validar todos os efeitos visíveis da criação da tarefa:

  1. Verificar se a submissão do formulário redireciona o usuário para a página de visualização da tarefa criada (viewTaskPage.isOpenWithUnknownTaskId()).
  2. Garantir que a página de visualização exibe a descrição da tarefa recém-criada (DESCRIPTION).
  3. Verificar se a página de visualização exibe o título da tarefa recém-criada (TITLE).
  4. Garantir que a seção de ciclo de vida exibe o nome do usuário logado (JohnDoe.NAME) como criador da tarefa!

Veja o código completo da nossa classe SubmitCreateTaskFormWithValidInformationTest:

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

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

    companion object {
        private const val DESCRIPTION = "This is a new lesson"
        private const val TITLE = "Write an extra lesson"
    }

    @SeleniumWebDriver
    lateinit var browser: WebDriver

    private lateinit var createTaskPage: CreateTaskPage

    @BeforeEach
    fun configureTestCases() {
        logUserIn()
        createTaskPage = CreateTaskPage(browser).open()
    }

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

    @Test
    fun `deve redirecionar para a pagina de visualizacao da tarefa criada`() {
        val viewTaskPage = createTask()
        assertThat(viewTaskPage.isOpenWithUnknownTaskId()).isTrue()
    }

    @Test
    fun `deve exibir a descricao da tarefa criada`() {
        val viewTaskPage = createTask()
        assertThat(viewTaskPage.getTaskDescription()).isEqualTo(DESCRIPTION)
    }

    @Test
    fun `deve exibir o titulo da tarefa criada`() {
        val viewTaskPage = createTask()
        assertThat(viewTaskPage.getTaskTitle()).isEqualTo(TITLE)
    }

    @Test
    fun `deve exibir o nome do usuario logado como criador da tarefa`() {
        val viewTaskPage = createTask()
        val lifeCycleFields = viewTaskPage.getTaskLifeCycleFields()

        assertThat(lifeCycleFields.getCreatorName()).isEqualTo(JohnDoe.NAME)
    }

    private fun createTask(): TaskPage {
        val createTaskForm = createTaskPage.getForm()
        createTaskForm.typeTitle(TITLE)
        createTaskForm.typeDescription(DESCRIPTION)
        return createTaskForm.submitTaskForm()
    }

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

Resumo

Esta aula nos ensinou duas lições fundamentais:

  • É uma excelente prática criar um Page Object dedicado para representar um formulário (TaskForm), permitindo reutilizá-lo entre as páginas de criação (CreateTaskPage) e de atualização (UpdateTaskPage) e mantendo as responsabilidades bem separadas.
  • Quando o ID da nova entidade é gerado dinamicamente pelo banco de dados durante o teste End-to-End, podemos validar o redirecionamento para /task/{id} com o método isOpenWithUnknownTaskId() e verificar os dados renderizados na página de destino (getTaskTitle(), getTaskDescription(), getCreatorName()).
  • No próximo tópico, avançaremos para um dos cenários mais desafiadores do mundo real: como escrever testes End-to-End para Single Page Applications (SPAs) em JavaScript, lidando com requisições AJAX/Fetch assíncronas, renderização dinâmica e diálogos modais!