Testes E2E para SPAs JavaScript: Formulários Dinâmicos

Depois de concluirmos esta aula, nós:

  • Entenderemos como selecionar a condição de espera correta (wait condition) quando estamos escrevendo testes End-to-End para funcionalidades que criam ou atualizam dados por meio de formulários dinâmicos em uma SPA.
  • Saberemos como fazer as alterações necessárias nos nossos Page Objects de formulário (UpdateTaskPage e TaskForm).

1. Escrevendo Testes End-to-End para Formulários em SPAs

Quando estamos escrevendo testes End-to-End para uma funcionalidade que exibe e submete um formulário em uma aplicação web JavaScript (SPA), tipicamente queremos escrever dois tipos de testes:

  1. Queremos garantir que os campos do formulário sejam pré-populados com os dados corretos (no caso de um formulário de edição/atualização).
  2. Queremos verificar que o usuário consegue preencher e submeter o formulário testado e fazer asserções sobre o resultado esperado.

Mais uma vez, como estamos testando uma SPA, não podemos confiar que o formulário e seus dados já estão prontos no DOM assim que abrimos a URL da página. Como escolhemos a condição de espera que deve ser verdadeira antes que o nosso teste End-to-End possa prosseguir?

Temos duas opções principais:

  1. Aguardar até que a chamada de API mais lenta tenha terminado e seus resultados tenham sido renderizados: Essa é uma excelente escolha se o nosso formulário possuir longas listas dropdown (<select>) ou autocompletes que são populados assincronamente com dados buscados do banco de dados.
  2. Aguardar até que o nosso caso de teste possa interagir com o formulário: Essa é a escolha ideal quando estamos escrevendo testes End-to-End para um formulário simples (como o formulário de tarefas) que só é exibido no DOM (ng-show="task" / renderização condicional) depois que os dados da entidade foram carregados da API REST!

Vamos ver um exemplo prático que demonstra como implementar essa lógica de espera para a funcionalidade de atualização de uma tarefa.


2. Atualizando as Informações de uma Tarefa (UpdateTaskPage e TaskForm)

Quando escrevemos testes End-to-End para a funcionalidade que atualiza os dados de uma tarefa existente, queremos garantir que:

  • Os campos do formulário de edição sejam preenchidos com os valores atuais da tarefa encontrada no banco de dados.
  • O usuário consiga alterar e salvar as novas informações da tarefa.

O código-fonte do template HTML da nossa SPA que renderiza o formulário de tarefa (#task-form) é o seguinte:

 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
<div class="panel panel-default">
    <div class="panel-body">
        <form id="task-form"
              name="taskForm"
              ng-show="task"
              role="form">
            <input id="task-id"
                   type="hidden"
                   ng-if="task.id"
                   value="{{task.id}}"/>
            <div class="form-group"
                 ng-class="{ 'has-error': taskForm.title.$dirty && taskForm.title.$invalid }">
                <label for="task-title" translate="task.property.title.label"></label>
                <input id="task-title"
                       class="form-control"
                       name="title"
                       ng-model="task.title"
                       required
                       ng-maxlength="100"/>
                <div ng-show="taskForm.title.$dirty && taskForm.title.$invalid">
                    <span id="error-title"
                          class="help-block"
                          translate="task.messages.title.required"
                          ng-show="taskForm.title.$error.required"></span>
                    <span id="error-title"
                          class="help-block"
                          translate="task.messages.title.maxLength"
                          ng-show="taskForm.title.$error.maxlength"></span>
                </div>
            </div>
            <div class="form-group"
                 ng-class="{ 'has-error': taskForm.description.$dirty && taskForm.description.$invalid }">
                <label for="task-description" translate="task.property.description.label"></label>
                <textarea id="task-description"
                          class="form-control"
                          name="description"
                          ng-model="task.description"
                          ng-maxlength="500"></textarea>
                <div ng-show="taskForm.description.$dirty && taskForm.description.$invalid">
                    <span id="error-description"
                          class="help-block"
                          translate="task.messages.description.maxLength"
                          ng-show="taskForm.description.$error.maxlength"></span>
                </div>
            </div>
            <button id="submit-task-form"
                    type="button"
                    class="btn btn-default"
                    translate="directive.task.form.submit.button.label"
                    ng-disabled="taskForm.$invalid || !taskForm.$dirty"
                    ng-click="saveTask()"></button>
        </form>
    </div>
</div>

Observe um detalhe importante nesse template:

  • O elemento <form id="task-form"> possui o atributo ng-show="task", o que significa que o formulário permanece oculto até que a requisição GET /api/task/{id} termine e popule o objeto task!
  • Além disso, o botão #submit-task-form começa desabilitado (ng-disabled="taskForm.$invalid || !taskForm.$dirty") até que o usuário modifique algum campo do formulário! Portanto, não podemos esperar que #submit-task-form seja clicável ao abrir a página, pois ele estará desabilitado até digitarmos algo.
  • Em vez disso, como nossos testes precisam ler ou editar o campo de título (#task-title), a condição de espera perfeita ao abrir a página do formulário é aguardar até que o campo de título (#task-title) esteja visível e clicável!

Podemos implementar essa condição de espera em dois passos:

Passo 1: Atualizar a Classe UpdateTaskPage

Primeiro, fazemos duas alterações na classe UpdateTaskPage:

  1. Adicionamos o método waitUntilPageIsOpen() à classe UpdateTaskPage, implementando-o para aguardar até que o campo #task-title seja clicável (SeleniumWait.waitUntilElementIsClickable(browser, By.id("task-title"))).
  2. Atualizamos o método open() para que ele invoque waitUntilPageIsOpen() antes de retornar o novo objeto UpdateTaskPage.

Depois de fazermos essas alterações, o código-fonte da classe UpdateTaskPage 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
package com.testwithspring.intermediate.web.task

import com.testwithspring.intermediate.SeleniumWait
import com.testwithspring.intermediate.WebDriverUrlBuilder
import org.openqa.selenium.By
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 getForm(): TaskForm {
        return TaskForm(browser)
    }

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

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

    fun waitUntilPageIsOpen() {
        SeleniumWait.waitUntilElementIsClickable(
            browser,
            By.id("task-title")
        )
    }
}

(De maneira idêntica, na classe CreateTaskPage, também invocamos waitUntilPageIsOpen() aguardando que #task-title esteja clicável ao abrir /task/create).

Passo 2: Atualizar a Classe TaskForm

Segundo, fazemos duas alterações na classe TaskForm:

  1. Simplificamos o construtor da classe TaskForm: Como na nossa SPA tanto o formulário de criação quanto o de atualização são renderizados pelo mesmo componente reutilizável, o id do elemento <form> é sempre "task-form". Por isso, podemos remover o parâmetro formId do construtor e buscar diretamente By.id("task-form").
  2. Atualizamos a implementação do método submitTaskForm(): Em vez de chamar form.submit() (que faria um POST síncrono tradicional), nossa nova implementação clica no botão #submit-task-form (form.findElement(By.id("submit-task-form")).click()) e aguarda até que a página de visualização da tarefa (TaskPage) esteja aberta (shown.waitUntilPageIsOpen()) antes de retornar o novo objeto TaskPage!

Depois de fazermos essas alterações, o código-fonte completo 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
48
49
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
) {
    private val form: WebElement = browser.findElement(By.id("task-form"))

    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.findElement(By.id("submit-task-form")).click()

        val shown = TaskPage(browser, taskId)
        shown.waitUntilPageIsOpen()
        return shown
    }

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

Mais uma vez, observe que não precisamos fazer nenhuma alteração nas nossas classes de teste End-to-End (SubmitCreateTaskFormWithValidInformationTest e SubmitUpdateTaskFormWithValidInformationTest)! Toda a complexidade de sincronização assíncrona com a SPA ficou 100% encapsulada dentro de UpdateTaskPage, CreateTaskPage e TaskForm.


Resumo

Esta aula nos ensinou duas regras práticas essenciais para testar formulários em SPAs:

  • Se o nosso formulário possuir longas listas dropdown (<select>) populadas assincronamente com dados vindos do banco de dados, é uma boa ideia aguardar até que essas listas tenham sido populadas.
  • Se estivermos escrevendo testes End-to-End para um formulário simples (cujo botão de submit pode iniciar desabilitado até que o formulário seja modificado), a melhor estratégia é aguardar até que nossos testes possam interagir com o primeiro campo de entrada do formulário (waitUntilElementIsClickable(browser, By.id("task-title"))).