Testes E2E para SPAs JavaScript: Aguardando APIs e Renderização de Dados

Depois de concluirmos esta aula, nós:

  • Entenderemos como escolher a condição de espera correta (wait condition) quando estamos escrevendo testes End-to-End para funcionalidades que buscam dados de uma API REST e os exibem na tela.
  • Saberemos como adaptar nossos Page Objects (TaskPage, TaskActions, TaskListPage e TaskListItem) para que todos os testes End-to-End existentes continuem passando sem alterar uma única linha nas classes de teste!

1. Escrevendo Testes End-to-End para Funcionalidades que Exibem Dados

Quando escrevemos testes End-to-End para uma funcionalidade que exibe dados em uma SPA, tipicamente queremos escrever dois tipos de testes:

  1. Queremos garantir que as informações corretas sejam exibidas para o usuário.
  2. Queremos verificar que os diferentes elementos HTML interativos (como links e botões) funcionam como esperado quando interagimos com eles.

Como vimos na aula anterior, em uma aplicação JavaScript (SPA) não podemos confiar que o documento HTML inteiro está pronto no instante em que navegamos para a URL da página: precisamos aguardar até que as chamadas de API REST terminem e o DOM seja construído pelo framework frontend. A grande pergunta é:

Como selecionamos a condição que deve ser verdadeira antes que o nosso teste End-to-End possa continuar?

Infelizmente, não existe uma resposta única e universal que sirva cegamente para todas as telas, e às vezes precisamos inspecionar o comportamento da página no navegador para encontrar a condição que elimina falhas intermitentes (flaky tests). Dito isso, uma excelente regra prática é:

  • Aguardar até que a chamada de API mais lenta tenha terminado e seus resultados tenham sido renderizados no DOM, ou
  • Aguardar até que possamos executar a operação interativa testada (como clicar em um link de ação que só aparece após o carregamento do componente).

Vamos analisar dois exemplos práticos da nossa aplicação para ver exatamente como aplicar esse raciocínio!


2. Renderizando um Único Item (TaskPage e TaskActions)

Quando escrevemos testes End-to-End para a funcionalidade que exibe os detalhes de uma única tarefa na SPA, queremos garantir que:

  • As informações corretas da tarefa sejam exibidas para o usuário.
  • O usuário possa excluir a tarefa exibida.
  • O usuário possa navegar para a página de atualização da tarefa.

O template HTML da view que exibe os detalhes de uma única tarefa na nossa SPA 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
47
48
49
50
51
52
53
54
55
56
57
58
<h1 id="task-title">{{task.title}}</h1>
<div class="panel panel-default">
    <div class="panel-body">
        <div id="task-description"
             ng-if="!task.description"
             translate="page.view.task.no.description.message"></div>
        <div id="task-description" ng-if="task.description">
            {{task.description}}
        </div>
    </div>
</div>
<div id="lifecycle-fields" class="container-fluid">
    <div class="row" ng-if="task.assignee != null">
        <div class="col-md-3">
            <span translate="page.view.task.assignee.description"></span>:
            <span id="assignee-name">{{task.assignee.name}}</span>
        </div>
    </div>
    <div class="row">
        <div class="col-md-3">
            <span translate="page.view.task.creator.description.prefix"></span>
            <span id="creator-name">{{task.creator.name}}</span>
            <span translate="page.view.task.timestamp.prefix"></span>
            <span id="creation-time">
                {{task.creationTime | amDateFormat:'YYYY-MM-DD H:mm:ss'}}
            </span>
        </div>
        <div class="col-md-3">
            <span translate="page.view.task.modifier.description.prefix"></span>
            <span id="modifier-name">{{task.modifier.name}}</span>
            <span translate="page.view.task.timestamp.prefix"></span>
            <span id="modification-time">
                {{task.modificationTime | amDateFormat:'YYYY-MM-DD H:mm:ss'}}
            </span>
        </div>
        <div id="closed-task-fields"
             class="col-md-3"
             ng-if="task.status == 'CLOSED'">
            <span translate="page.view.task.closer.description.prefix"></span>
            <span id="closer-name">{{task.closer.name}}</span>
            <span translate="page.view.task.timestamp.prefix"></span>
            <span id="closing-time">
                {{task.modificationTime | amDateFormat:'YYYY-MM-DD H:mm:ss'}}
            </span>
        </div>
    </div>
</div>
<div id="task-action-links" class="row">
    <div class="col-md-1">
        <span delete-task-link task="task"></span>
    </div>
    <div class="col-md-1">
        <a id="update-task-link"
           ui-sref="task.update({id: task.id})"
           translate="page.view.task.update.task.link.label">
        </a>
    </div>
</div>

Qual condição devemos utilizar para aguardar até que o documento HTML dessa view tenha sido completamente construído?

Ao analisarmos o funcionamento dessa tela, notamos duas coisas:

  1. Buscar os dados da tarefa exibida (GET /api/task/{id}) é a chamada de API mais lenta (e a única) que precisa terminar antes que a view exiba os dados da tarefa.
  2. Não podemos executar todos os nossos testes End-to-End se os testes não puderem clicar nos links de excluir e atualizar a tarefa.

Ao inspecionarmos a página no DevTools do navegador, vemos que a view inteira é renderizada de uma só vez assim que a resposta JSON da tarefa retorna. Por isso, podemos aguardar até que nossos testes possam clicar no link de atualizar a tarefa (#update-task-link)!

Podemos implementar essa lógica de espera fazendo duas alterações na classe TaskPage:

  1. Adicionar um método waitUntilPageIsOpen() à classe TaskPage que aguarda até que o elemento #update-task-link seja clicável (SeleniumWait.waitUntilElementIsClickable(browser, By.id("update-task-link"))).
  2. Atualizar o método open() para que ele invoque waitUntilPageIsOpen() logo após browser.get(pageUrl) antes de retornar o novo objeto TaskPage.

Veja como fica a classe TaskPage com essas alterações:

 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 com.testwithspring.intermediate.SeleniumWait
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)

    // Outros métodos (getTaskTitle, getTaskDescription, etc.) permanecem idênticos!

    fun open(): TaskPage {
        if (taskId == null) {
            throw RuntimeException("O taskId não foi especificado")
        }

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

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

Além disso, antes de podermos executar os testes End-to-End que clicam nos links da página de visualização da tarefa, precisamos atualizar o método updateTask() na classe TaskActions. Em vez de aguardar apenas a mudança de URL (que em uma SPA ocorre instantaneamente no cliente antes mesmo da API responder!), agora invocamos targetPage.waitUntilPageIsOpen() antes de retornar o UpdateTaskPage:

 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 org.openqa.selenium.By
import org.openqa.selenium.WebDriver
import org.openqa.selenium.WebElement

internal class TaskActions(
    private val browser: WebDriver,
    private val rootElement: WebElement,
    private val taskId: Long?
) {
    // Veremos a atualização do método deleteTask() na Aula 04 (Diálogos Modais)

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

        val targetPage = UpdateTaskPage(browser, taskId)
        targetPage.waitUntilPageIsOpen()

        return targetPage
    }
}

(Veremos a implementação de UpdateTaskPage.waitUntilPageIsOpen() na próxima aula, quando abordarmos formulários dinâmicos).

Vamos descobrir agora como implementar a lógica de espera para uma funcionalidade que renderiza uma lista de itens!


3. Renderizando uma Lista de Itens (TaskListPage e TaskListItem)

A view que renderiza a página de listagem de tarefas na nossa SPA possui três requisitos:

  • Deve exibir uma mensagem informando quantas tarefas foram encontradas no banco de dados (#task-list-size-message).
  • Deve exibir o título e o status traduzido de cada tarefa.
  • Deve fornecer um link em cada tarefa que abre a página de visualização daquela tarefa.

O código do template principal da view de listagem é o seguinte:

1
2
3
4
5
6
<h1 translate="page.task.list.title"></h1>
<div id="task-list-size-message"
     translate="page.task.list.size.message"
     translate-values="{ taskCount: tasks.length }"></div>
<div task-list tasks="tasks">
</div>

E a tabela de tarefas propriamente dita é renderizada por um componente/diretiva cujo template HTML é 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
<table id="task-list"
       class="table table-striped"
       ng-if="tasks.length > 0">
    <thead>
    <tr>
        <th translate="task.property.title.header"></th>
        <th translate="task.property.status.header"></th>
    </tr>
    </thead>
    <tbody>
    <tr id="task-list-item-{{task.id}}"
        class="task-list-item"
        ng-repeat="task in tasks">
        <td>
            <a class="task-list-item-title"
               ui-sref="task.view({id: task.id})">{{task.title}}</a>
        </td>
        <td class="task-list-item-status task-status-{{task.status}}"
            translate="TaskStatus.{{task.status}}">
        </td>
    </tr>
    </tbody>
</table>

Quando olhamos para esses templates, notamos que buscar a lista de tarefas (GET /api/task) é a chamada de API mais lenta (e a única) que precisa terminar antes que a aplicação possa renderizar as linhas <tr class="task-list-item">. Portanto, a condição de espera ideal é aguardar até que todos os itens da lista (.task-list-item) estejam visíveis na interface de usuário!

Podemos implementar essa lógica de espera em três passos:

Passo 1: Adicionar waitUntilAllElementsAreVisible() ao SeleniumWait

Primeiro, adicionamos um novo método estático waitUntilAllElementsAreVisible() ao nosso objeto SeleniumWait, utilizando ExpectedConditions.visibilityOfAllElementsLocatedBy(elementLocator):

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

import org.openqa.selenium.By
import org.openqa.selenium.WebDriver
import org.openqa.selenium.support.ui.ExpectedConditions
import org.openqa.selenium.support.ui.WebDriverWait
import java.time.Duration

object SeleniumWait {

    private const val DEFAULT_TIMEOUT_IN_SECONDS = 5L

    @JvmStatic
    fun waitUntilElementIsClickable(browser: WebDriver, elementLocator: By) {
        val wait = WebDriverWait(browser, Duration.ofSeconds(DEFAULT_TIMEOUT_IN_SECONDS))
        wait.until(ExpectedConditions.elementToBeClickable(elementLocator))
    }

    @JvmStatic
    fun waitUntilAllElementsAreVisible(browser: WebDriver, elementLocator: By) {
        val wait = WebDriverWait(browser, Duration.ofSeconds(DEFAULT_TIMEOUT_IN_SECONDS))
        wait.until(ExpectedConditions.visibilityOfAllElementsLocatedBy(elementLocator))
    }

    @JvmStatic
    fun waitUntilElementIsVisible(browser: WebDriver, elementLocator: By) {
        val wait = WebDriverWait(browser, Duration.ofSeconds(DEFAULT_TIMEOUT_IN_SECONDS))
        wait.until(ExpectedConditions.visibilityOfElementLocated(elementLocator))
    }
}

Passo 2: Atualizar a Classe TaskListPage

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

  1. Adicionamos o método waitUntilPageIsOpen(), que aguarda até que todos os elementos com a classe .task-list-item estejam visíveis na tela.
  2. Atualizamos o método open() para invocar waitUntilPageIsOpen() antes de retornar o novo objeto TaskListPage.
 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
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

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

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

    fun open(): TaskListPage {
        browser.get(pageUrl)
        waitUntilPageIsOpen()

        return TaskListPage(browser)
    }

    fun waitUntilPageIsOpen() {
        SeleniumWait.waitUntilAllElementsAreVisible(
            browser,
            By.className("task-list-item")
        )
    }
}

Passo 3: Atualizar o Método viewTask() na Classe TaskListItem

Terceiro, atualizamos o método viewTask() da classe TaskListItem para que, após clicar no link do título da tarefa, ele aguarde a página de detalhes da tarefa terminar de renderizar invocando targetPage.waitUntilPageIsOpen():

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

internal class TaskListItem(
    private val browser: WebDriver,
    private val rootElement: WebElement
) {
    fun getId(): Long {
        val elementId = rootElement.getAttribute("id") ?: ""
        val taskId = elementId.replace("task-list-item-", "")
        return taskId.toLong()
    }

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

    fun getStatus(): TaskStatus {
        val statusElement = rootElement.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 = rootElement.findElement(By.className("task-list-item-title"))
        val targetPage = TaskPage(browser, getId())

        viewTaskLink.click()
        targetPage.waitUntilPageIsOpen()

        return targetPage
    }
}

O Poder do Padrão Page Object: Com essas pequenas alterações encapsuladas exclusivamente dentro de TaskPage, TaskActions, TaskListPage e TaskListItem, todas as nossas classes de teste End-to-End existentes (RenderViewTaskPageTest e RenderTaskListPageTest) passam a funcionar perfeitamente contra a SPA em JavaScript sem precisarmos alterar uma única linha de código nos testes!


Resumo

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

  • Em aplicações JavaScript (SPAs), não basta esperar a URL do navegador mudar (ExpectedConditions.urlToBe); precisamos aguardar que os elementos do DOM dependentes da API REST sejam efetivamente renderizados.
  • Se quisermos garantir que os dados corretos sejam exibidos ao usuário (como em uma tabela ou lista), devemos aguardar até que a chamada de API mais lenta termine e todos os itens sejam renderizados (waitUntilAllElementsAreVisible).
  • Se quisermos verificar que o usuário pode realizar uma ação na página, devemos aguardar até que o elemento de ação correspondente esteja clicável (waitUntilElementIsClickable).