Configurando Testes E2E com Spring Boot (Servidor Embarcado e Listeners)

Depois de ler esta aula, você entenderá como podemos configurar nossos testes End-to-End quando estamos escrevendo testes para uma aplicação web que utiliza o Spring Boot.

Durante esta aula, utilizaremos o navegador Google Chrome, mas exatamente as mesmas técnicas funcionam com outros navegadores suportados ou com o Playwright.

Vamos começar descobrindo como podemos iniciar nossa aplicação web quando nossos testes End-to-End forem executados.


1. Iniciando Nossa Aplicação Web nos Testes E2E

Quando estamos escrevendo testes End-to-End para uma aplicação Spring Boot, precisamos iniciar nossa aplicação — junto com seu servidor HTTP embarcado (Tomcat, Jetty ou Netty) — antes que nossos testes sejam executados. Podemos fazer isso anotando nossa classe de teste com @SpringBootTest e configurando seu atributo webEnvironment para WebEnvironment.DEFINED_PORT (ou WebEnvironment.RANDOM_PORT).

Isso garante que o Spring Boot Test carregue o ApplicationContext real da aplicação e inicie o container de servlets embarcado ouvindo na porta HTTP configurada no arquivo application.properties (ou na porta padrão 8080, caso não tenhamos configurado uma porta customizada).

Nota sobre DEFINED_PORT vs RANDOM_PORT:

  • No curso original do Test With Spring, utiliza-se WebEnvironment.DEFINED_PORT (porta 8080) para que os métodos utilitários de URL nos Page Objects possam construir os endereços a partir de uma constante centralizada.
  • Em pipelines de CI/CD modernos onde múltiplos builds podem rodar na mesma máquina simultaneamente, você também pode utilizar WebEnvironment.RANDOM_PORT e injetar a porta dinâmica alocada pelo sistema operacional com a anotação @LocalServerPort private var port: Int = 0. Ambos os modos iniciam o servidor HTTP embarcado real!

Depois de anotarmos nossa classe de teste End-to-End com @SpringBootTest, o código-fonte fica assim:

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

import com.testwithspring.intermediate.EndToEndTest
import com.testwithspring.intermediate.TaskTrackerApplication
import org.junit.jupiter.api.Tag
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment

@SpringBootTest(
    classes = [TaskTrackerApplication::class],
    webEnvironment = WebEnvironment.DEFINED_PORT
)
@Tag("endToEndTest")
class EndToEndTestExample {
}

Vamos descobrir a seguir como podemos gerenciar o ciclo de vida dos nossos objetos WebDriver integrados ao contexto de teste do Spring.


2. Gerenciando o Ciclo de Vida dos Objetos WebDriver

Se não quisermos gerenciar o ciclo de vida dos nossos objetos WebDriver manualmente em cada classe de teste, precisamos encontrar uma maneira de:

  1. Criar um novo objeto WebDriver antes que um método de setup (@BeforeEach) seja invocado. Isso garante que possamos utilizar o objeto WebDriver criado dentro dos nossos métodos @BeforeEach.
  2. Injetar o objeto WebDriver criado em nossa classe de teste utilizando a anotação @Autowired padrão do Spring.
  3. Fechar o objeto WebDriver (quit()) depois que todos os métodos de teste de uma classe de teste tiverem sido executados.

Podemos implementar essas funcionalidades criando uma anotação customizada chamada @SeleniumTest e um TestExecutionListener customizado do Spring TestContext Framework que gerencia o ciclo de vida do nosso objeto WebDriver:

Passo 1: Criando a Anotação @SeleniumTest com @TestExecutionListeners

Primeiro, criamos a anotação @SeleniumTest. Utilizamos essa anotação para configurar duas coisas essenciais:

  1. A classe TestExecutionListener (SeleniumTestExecutionListener) que será responsável por gerenciar o ciclo de vida do objeto WebDriver.
  2. O tipo da classe WebDriver que será instanciada antes da execução dos testes.

Ao inspecionarmos o código-fonte da anotação @SeleniumTest, vemos que:

  • Ela é anotada com @TestExecutionListeners. Quando configuramos nosso TestExecutionListener customizado, precisamos definir o atributo mergeMode como MergeMode.MERGE_WITH_DEFAULTS. Isso garante que o nosso SeleniumTestExecutionListener seja adicionado à lista padrão de listeners do Spring Boot (como DependencyInjectionTestExecutionListener e SqlScriptsTestExecutionListener), em vez de substituí-los!
  • Ela possui um atributo driver que retorna o tipo do WebDriver (KClass<out WebDriver>) que será instanciado, com valor padrão ChromeDriver::class.

O código-fonte da anotação SeleniumTest fica assim:

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

import org.openqa.selenium.WebDriver
import org.openqa.selenium.chrome.ChromeDriver
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.TestExecutionListeners.MergeMode
import kotlin.reflect.KClass

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
@MustBeDocumented
@TestExecutionListeners(
    listeners = [SeleniumTestExecutionListener::class],
    mergeMode = MergeMode.MERGE_WITH_DEFAULTS
)
annotation class SeleniumTest(
    val driver: KClass<out WebDriver> = ChromeDriver::class
)

Passo 2: Implementando o SeleniumTestExecutionListener

Segundo, criamos um TestExecutionListener customizado chamado SeleniumTestExecutionListener, que gerencia o ciclo de vida do nosso objeto WebDriver e o registra no ApplicationContext do Spring para que o DependencyInjectionTestExecutionListener possa injetá-lo via @Autowired na classe de teste!

Podemos escrever essa classe seguindo estes 7 passos:

  1. Criar a classe SeleniumTestExecutionListener estendendo AbstractTestExecutionListener e adicionar uma propriedade privada browser: WebDriver? nela.
  2. Sobrescrever o método getOrder() da interface Ordered e retornar Ordered.HIGHEST_PRECEDENCE. Isso garante que o nosso SeleniumTestExecutionListener seja invocado antes do DependencyInjectionTestExecutionListener (que realiza a injeção de dependências @Autowired na instância de teste).
  3. Sobrescrever o método prepareTestInstance(testContext: TestContext) da classe AbstractTestExecutionListener. O Spring TestContext Framework invoca esse método antes de preparar a instância da classe de teste.
  4. Encontrar a anotação @SeleniumTest na classe de teste utilizando AnnotationUtils.findAnnotation().
  5. Instanciar um novo objeto WebDriver utilizando BeanUtils.instantiateClass() (se o browser ainda não tiver sido criado para essa classe) e armazená-lo no campo browser.
  6. Obter o ConfigurableApplicationContext a partir do TestContext e registrar o objeto WebDriver criado na ConfigurableListableBeanFactory subjacente utilizando registerResolvableDependency(WebDriver::class.java, browser). Isso permite que o Spring injete essa instância específica sempre que encontrar @Autowired lateinit var browser: WebDriver na classe de teste!
  7. Sobrescrever o método afterTestClass(testContext: TestContext) da classe AbstractTestExecutionListener. Dentro desse método, se o campo browser não for nulo, fechamos o navegador chamando browser.quit() e limpamos a referência.

Vamos analisar o código-fonte completo do SeleniumTestExecutionListener em Kotlin:

 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

import org.openqa.selenium.WebDriver
import org.springframework.beans.BeanUtils
import org.springframework.context.ConfigurableApplicationContext
import org.springframework.core.Ordered
import org.springframework.core.annotation.AnnotationUtils
import org.springframework.test.context.TestContext
import org.springframework.test.context.support.AbstractTestExecutionListener

class SeleniumTestExecutionListener : AbstractTestExecutionListener() {

    private var browser: WebDriver? = null

    // Passo 2: Executa ANTES do DependencyInjectionTestExecutionListener
    override fun getOrder(): Int {
        return Ordered.HIGHEST_PRECEDENCE
    }

    // Passos 3 a 6: Instancia o WebDriver e o registra no ApplicationContext do Spring
    override fun prepareTestInstance(testContext: TestContext) {
        if (browser != null) {
            return
        }

        val testClass = testContext.testClass
        val seleniumTest = AnnotationUtils.findAnnotation(testClass, SeleniumTest::class.java)
            ?: throw IllegalStateException(
                "A anotação @SeleniumTest não foi encontrada na classe: ${testClass.name}"
            )

        val currentBrowser = BeanUtils.instantiateClass(seleniumTest.driver.java)
        this.browser = currentBrowser

        val context = testContext.applicationContext
        if (context is ConfigurableApplicationContext) {
            val bf = context.beanFactory
            bf.registerResolvableDependency(WebDriver::class.java, currentBrowser)
        }
    }

    // Passo 7: Fecha o navegador ao final da execução da classe de teste
    override fun afterTestClass(testContext: TestContext) {
        browser?.quit()
        browser = null
    }
}

3. Injetando o WebDriver na Classe de Teste com @Autowired

Depois de criarmos a anotação @SeleniumTest e o SeleniumTestExecutionListener, podemos configurar nossa classe de teste End-to-End seguindo estes dois passos simples:

  1. Anotar a classe de teste com @SeleniumTest. Como o valor padrão do atributo driver é ChromeDriver::class, não precisamos configurar o tipo da classe WebDriver manualmente.
  2. Adicionar uma propriedade lateinit var browser: WebDriver na classe de teste e anotá-la com a anotação padrão @Autowired do Spring.

Depois de seguirmos esses passos, o código-fonte da nossa classe de teste End-to-End 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
package com.testwithspring.intermediate.web

import com.testwithspring.intermediate.SeleniumTest
import com.testwithspring.intermediate.TaskTrackerApplication
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.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment

@SpringBootTest(
    classes = [TaskTrackerApplication::class],
    webEnvironment = WebEnvironment.DEFINED_PORT
)
@SeleniumTest
@Tag("endToEndTest")
class EndToEndTestExample {

    @Autowired
    lateinit var browser: WebDriver

    @BeforeEach
    fun setUp() {
        // O 'browser' já foi injetado pelo Spring antes do @BeforeEach!
    }

    @Test
    fun `deve abrir a aplicacao rodando no servidor embarcado`() {
        browser.get("http://localhost:8080")
    }
}

Resumo

Nesta aula, aprendemos que:

  • Podemos iniciar nossa aplicação web Spring Boot (com servidor HTTP embarcado real) anotando nossa classe de teste End-to-End com @SpringBootTest e configurando webEnvironment = WebEnvironment.DEFINED_PORT (ou RANDOM_PORT).
  • Podemos gerenciar o ciclo de vida dos nossos objetos WebDriver implementando um TestExecutionListener customizado (SeleniumTestExecutionListener) com Ordered.HIGHEST_PRECEDENCE.
  • Ao registrar a instância do WebDriver com beanFactory.registerResolvableDependency(WebDriver::class.java, browser), podemos simplesmente injetar o navegador na nossa classe de teste utilizando @Autowired.
  • Nas próximas duas aulas, veremos como inicializar o schema e popular os dados de teste no banco de dados para nossos cenários End-to-End utilizando o Liquibase e a anotação @Sql.