Testes E2E para SPAs JavaScript: Introdução e Desafios de Assincronicidade

Depois de concluirmos esta aula, nós:

  • Saberemos a diferença crucial entre uma aplicação web tradicional (Server-Side Rendered) e uma aplicação web JavaScript (SPA) sob a ótica dos testes End-to-End.
  • Entenderemos o que precisamos levar em consideração quando escrevemos testes End-to-End para aplicações web JavaScript.
  • Saberemos fazer as alterações necessárias nos componentes e Page Objects utilizados para configurar nossos testes End-to-End (WebDriverEnvironment, WebDriverUrlBuilder, SeleniumWait, LoginPage e NavigationBar).

1. A Diferença Entre uma Aplicação Web Tradicional e uma Aplicação Web JavaScript (SPA)

Naturalmente, uma aplicação web tradicional (renderizada no servidor via Thymeleaf/JSP) e uma aplicação web JavaScript (Single Page Application em React, Vue, Angular ou AngularJS) possuem várias diferenças arquiteturais. Entretanto, quando pensamos em testes End-to-End, a diferença mais crucial é:

  • Uma aplicação web tradicional constrói o documento HTML inteiro no backend antes de retornar a resposta HTTP de volta para o navegador. Quando browser.get() retorna, todos os elementos já estão no DOM.
  • A interface de usuário de uma aplicação web JavaScript (SPA) consiste em componentes de UI assíncronos que só se tornam visíveis quando seus dados chegam da API REST.

Em outras palavras, quando escrevemos testes End-to-End para uma aplicação web JavaScript, devemos seguir rigorosamente estas duas regras:

  1. Devemos garantir que é possível interagir com um componente de UI antes de tentarmos interagir com ele (por exemplo, aguardar que um botão ou input esteja visível e habilitado antes de clicar ou digitar).
  2. Devemos verificar que a informação desejada já está visível no DOM antes de executarmos asserções sobre ela.

Vamos avançar e descobrir quais alterações precisamos fazer antes de configurarmos nossos testes End-to-End para uma SPA.


2. Habilitando e Desabilitando o Modo HTML5 de Roteamento no Frontend

Quando nossa aplicação frontend utiliza roteamento no lado do cliente (como o HTML5 mode do AngularJS / History API vs Hashbang Router #/), precisamos ser capazes de habilitar ou desabilitar o modo HTML5 nos nossos utilitários de URL porque isso afeta diretamente os endereços URL utilizados pela nossa aplicação web:

  • Se o modo HTML5 estiver desabilitado, nossa aplicação web utiliza endereços URL contendo um hash (http://localhost:8080/#/user/login).
  • Se o modo HTML5 estiver habilitado, nossa aplicação utiliza endereços URL “normais” da History API que não contêm o caractere # (http://localhost:8080/user/login).

Antes de escrevermos os testes End-to-End para a nossa SPA, precisamos construir nossos endereços URL de uma forma que suporte ambas as opções de configuração. Podemos fazer isso em dois passos:

Passo 1: Adicionar o Método isHtml5ModeEnabled() ao WebDriverEnvironment

Primeiro, adicionamos um novo método estático isHtml5ModeEnabled() ao objeto WebDriverEnvironment. Esse método lê o valor da propriedade de sistema webdriver.html5.mode.enabled e retorna true se o modo HTML5 estiver habilitado e false caso contrário. Se a propriedade de sistema não for encontrada, o método lança uma RuntimeException:

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

object WebDriverEnvironment {

    @JvmStatic
    fun getBaseUrl(): String {
        return System.getProperty("webdriver.base.url")
            ?: throw RuntimeException("Nenhuma URL base encontrada! Configure 'webdriver.base.url'.")
    }

    @JvmStatic
    fun isHtml5ModeEnabled(): Boolean {
        val html5Mode = System.getProperty("webdriver.html5.mode.enabled")
            ?: throw RuntimeException("Modo HTML5 não encontrado! Configure 'webdriver.html5.mode.enabled'.")
        return html5Mode.toBoolean()
    }
}

Passo 2: Atualizar o Método buildFromPath() no WebDriverUrlBuilder

Segundo, fazemos uma alteração no método buildFromPath() da classe WebDriverUrlBuilder: se o modo HTML5 estiver desabilitado (!WebDriverEnvironment.isHtml5ModeEnabled()), concatenamos a string "#/" logo após a barra final da URL base do servidor!

Depois de fazermos essa alteração, o código-fonte do WebDriverUrlBuilder 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
package com.testwithspring.intermediate

object WebDriverUrlBuilder {

    @JvmStatic
    fun buildFromPath(path: String?, vararg params: Any?): String {
        if (path.isNullOrEmpty()) {
            throw IllegalArgumentException("O caminho (path) deve ser informado")
        }

        var formattedPath = String.format(path, *params)

        var baseUrl = WebDriverEnvironment.getBaseUrl()
        if (!baseUrl.endsWith("/")) {
            baseUrl += "/"
        }

        if (!WebDriverEnvironment.isHtml5ModeEnabled()) {
            baseUrl += "#/"
        }

        if (formattedPath.startsWith("/")) {
            formattedPath = formattedPath.replaceFirst("/", "")
        }

        return baseUrl + formattedPath
    }
}

Vamos descobrir agora como escrever testes End-to-End que aguardam até que uma condição específica do DOM seja atendida.


3. Aguardando Até Que uma Condição Específica Seja Atendida (SeleniumWait)

Como estamos escrevendo testes End-to-End para uma aplicação web JavaScript (SPA), não podemos assumir que todos os componentes de UI estão visíveis imediatamente após abrirmos uma rota. É por isso que a maioria dos nossos testes End-to-End precisa seguir este fluxo de 3 etapas:

  1. Executar uma operação que dispara alterações assíncronas no DOM (como abrir uma página ou clicar em um botão).
  2. Aguardar até que o DOM tenha sido atualizado.
  3. Executar as asserções necessárias.

Como muitos Page Objects precisarão de condições de espera semelhantes, é uma excelente ideia criar uma classe utilitária chamada SeleniumWait para eliminar código duplicado. Podemos criar essa classe seguindo estes três passos:

  1. Criar um object (ou classe final com construtor privado) chamado SeleniumWait.
  2. Adicionar uma constante privada chamada DEFAULT_TIMEOUT_IN_SECONDS com o valor 5L (5 segundos).
  3. Adicionar dois métodos utilitários iniciais:
    • waitUntilElementIsClickable(browser: WebDriver, elementLocator: By): Aguarda até que o elemento especificado esteja visível e habilitado para receber cliques (ExpectedConditions.elementToBeClickable).
    • waitUntilElementIsVisible(browser: WebDriver, elementLocator: By): Aguarda até que o elemento especificado esteja presente no DOM e visível na tela (ExpectedConditions.visibilityOfElementLocated).

Depois de implementarmos esses métodos, o código-fonte de SeleniumWait 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
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 waitUntilElementIsVisible(browser: WebDriver, elementLocator: By) {
        val wait = WebDriverWait(browser, Duration.ofSeconds(DEFAULT_TIMEOUT_IN_SECONDS))
        wait.until(ExpectedConditions.visibilityOfElementLocated(elementLocator))
    }
}

Depois de criarmos esses métodos utilitários, precisamos atualizar os dois Page Objects responsáveis por autenticar o usuário antes de cada método de teste (LoginPage) e desconectar o usuário após cada método de teste (NavigationBar).


4. Autenticando o Usuário Antes do Teste na SPA (LoginPage)

A maior diferença entre o formulário de login da nossa aplicação web tradicional e o da nossa SPA em JavaScript é como a submissão acontece:

  • Na aplicação web tradicional, clicar no botão de login dispara um submit nativo de formulário HTML (POST /user/login).
  • Na nossa aplicação JavaScript (SPA), clicar no botão #login-submit-button invoca uma função JavaScript (submitLoginForm()) que envia uma requisição AJAX/Fetch assíncrona em background!

O template HTML que renderiza o formulário de login da 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
<div id="login-error-alert"
     class="alert alert-danger alert-dismissible"
     ng-if="loginFailed">
    <button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
    <span translate="directive.login.form.login.failed.error.message"></span>
</div>
<div ng-if="!currentUser.username">
    <form id="login-form" role="form">
        <div class="form-group">
            <label for="email-address" translate="directive.login.form.email.address.label"></label>
            <input id="email-address"
                   class="form-control"
                   name="username"
                   type="email"
                   placeholder="{{'email.address.placeholder' | translate}}"
                   ng-model="login.username">
        </div>
        <div class="form-group">
            <label for="password" translate="directive.login.form.password.label"></label>
            <input id="password"
                   class="form-control"
                   name="password"
                   type="password"
                   placeholder="{{'email.address.placeholder' | translate}}"
                   ng-model="login.password">
        </div>
        <button id="login-submit-button"
                type="button"
                class="btn btn-default"
                translate="directive.login.form.submit.button.label"
                ng-click="submitLoginForm()">
        </button>
    </form>
</div>
<div id="authenticated-user-error"
     ng-if="currentUser.username"
     translate="page.login.authenticated.user.error.message">
</div>

Nossos testes End-to-End utilizam a classe LoginPage para autenticar o usuário no @BeforeEach. Como precisamos garantir que o formulário de login já foi renderizado pelo JavaScript antes de tentarmos digitar o e-mail e a senha, fazemos quatro alterações na classe LoginPage:

  1. Escrevemos o método waitUntilPageIsOpen(), que aguarda até que nosso teste possa clicar no botão #login-submit-button (SeleniumWait.waitUntilElementIsClickable(browser, By.id("login-submit-button"))). Isso garante que os testes não tentem digitar o e-mail e a senha antes que o componente de login esteja pronto no DOM.
  2. Alteramos a implementação do método privado submitLoginForm(): em vez de chamar .submit() no <form>, agora clicamos diretamente no botão #login-submit-button (browser.findElement(By.id("login-submit-button")).click()).
  3. Renomeamos o método open() para openAsAnonymousUser() e garantimos que ele chame waitUntilPageIsOpen() antes de retornar o novo objeto LoginPage. (Renomeamos esse método porque, se abrirmos a página de login como um usuário já autenticado, o botão #login-submit-button não será renderizado e usaremos outra condição de espera!).
  4. Alteramos a implementação do método login(): após clicar no botão de login, ele agora chama shown.waitUntilPageIsOpen() na TaskListPage antes de retorná-la!

Depois de aplicarmos essas quatro mudanças, o código-fonte da classe LoginPage 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
package com.testwithspring.intermediate.web.login

import com.testwithspring.intermediate.SeleniumWait
import com.testwithspring.intermediate.WebDriverUrlBuilder
import com.testwithspring.intermediate.web.task.TaskListPage
import org.openqa.selenium.By
import org.openqa.selenium.WebDriver

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

    fun openAsAnonymousUser(): LoginPage {
        browser.get(pageUrl)
        waitUntilPageIsOpen()

        return LoginPage(browser)
    }

    fun login(emailAddress: String, password: String): TaskListPage {
        typeEmailAddress(emailAddress)
        typePassword(password)
        submitLoginForm()

        val shown = TaskListPage(browser)
        shown.waitUntilPageIsOpen()

        return shown
    }

    fun waitUntilPageIsOpen() {
        SeleniumWait.waitUntilElementIsClickable(
            browser,
            By.id("login-submit-button")
        )
    }

    private fun typeEmailAddress(emailAddress: String) {
        browser.findElement(By.id("email-address")).sendKeys(emailAddress)
    }

    private fun typePassword(password: String) {
        browser.findElement(By.id("password")).sendKeys(password)
    }

    private fun submitLoginForm() {
        browser.findElement(By.id("login-submit-button")).click()
    }
}

(Veremos a implementação detalhada do método TaskListPage.waitUntilPageIsOpen() na próxima aula deste tópico).

A seguir, faremos as alterações necessárias no Page Object NavigationBar para desconectar o usuário ao final de cada teste.


5. Fazendo Logout do Usuário Após o Teste na SPA (NavigationBar)

Nossos testes End-to-End utilizam a classe NavigationBar quando querem desconectar o usuário no @AfterEach. Precisamos reescrever o método logUserOut() porque nossa nova interface JavaScript não possui mais um <form id="logout-form">: em vez disso, o usuário é desconectado quando clica no botão <button id="log-out-button">, que dispara uma chamada assíncrona de logout!

O template HTML que renderiza o botão de logout na SPA é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<ul class="nav navbar-nav navbar-right" ng-if="currentUser.username">
    <li>
        <button id="log-out-button"
                type="button"
                class="btn btn-default navbar-btn"
                translate="directive.logout.button.button.label"
                ng-click="logOut()">
        </button>
    </li>
</ul>

Podemos reescrever o método logUserOut() seguindo estes quatro passos:

  1. Lançar uma RuntimeException caso o botão de logout (#log-out-button) não esteja visível na interface de usuário.
  2. Clicar no botão #log-out-button.
  3. Aguardar até que a página de login esteja aberta e pronta (shown.waitUntilPageIsOpen()).
  4. Retornar o novo objeto LoginPage.

Depois de fazermos essas alterações na classe NavigationBar, seu código-fonte fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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("log-out-button")).isEmpty()) {
            throw RuntimeException("O botão de logout (#log-out-button) não está visível na página.")
        }

        browser.findElement(By.id("log-out-button")).click()

        val shown = LoginPage(browser)
        shown.waitUntilPageIsOpen()
        return shown
    }
}

Resumo

Esta aula nos ensinou três lições fundamentais sobre testes End-to-End em aplicações JavaScript (SPAs):

  • Devemos garantir que é possível interagir com um componente de UI (waitUntilElementIsClickable) antes de tentarmos clicar ou digitar nele.
  • Devemos verificar que a informação necessária já foi renderizada no DOM (waitUntilElementIsVisible) antes de executarmos asserções sobre ela.
  • Devemos centralizar nossa lógica de espera explícita em uma classe utilitária (SeleniumWait) e dentro dos métodos waitUntilPageIsOpen() dos nossos Page Objects, eliminando código duplicado e mantendo as classes de teste limpas.