Ciclo de Vida do Browser e Contexto Isolado por Teste (Spring Framework)

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

Durante esta aula, utilizaremos o navegador Google Chrome (e equivalentes modernos como o Chromium via Playwright/Selenium), mas técnicas exatamente idênticas também funcionam com outros navegadores suportados.

Vamos começar descobrindo como podemos configurar nossa classe de teste.


1. Configurando Nossa Classe de Teste

Se não quisermos gerenciar o ciclo de vida dos nossos objetos WebDriver (ou Page / BrowserContext no Playwright) manualmente dentro das classes de teste, precisamos encontrar uma maneira de:

  1. Criar um novo objeto WebDriver antes que um método de configuração (@BeforeEach) seja invocado. Isso garante que possamos utilizar o objeto WebDriver criado dentro dos nossos métodos de setup.
  2. Injetar o objeto WebDriver criado em nossa classe de teste para que os métodos @BeforeEach e @Test tenham acesso imediato a ele.
  3. Fechar o navegador (quit()) depois que um método de teardown (@AfterEach) tiver sido executado. Isso garante que ainda possamos utilizar o objeto WebDriver dentro dos nossos métodos de limpeza caso necessário (por exemplo, para capturar screenshots ou logs em caso de falha).

Podemos implementar essas funcionalidades criando duas anotações customizadas e uma extensão/runner do JUnit que gerencia o ciclo de vida do nosso objeto WebDriver (no JUnit 4 original usávamos um BlockJUnit4ClassRunner customizado chamado SeleniumTestRunner; no JUnit 6 (Jupiter), implementamos esse mesmo mecanismo de 11 passos de forma limpa através das interfaces BeforeEachCallback, AfterEachCallback e ParameterResolver, além de injeção direta no campo anotado).

Vamos começar criando as duas anotações customizadas que são utilizadas para configurar a classe WebDriver instanciada e identificar o campo onde o objeto WebDriver criado será injetado:

Passo 1: Criando a Anotação @SeleniumTest

Primeiro, criamos uma anotação chamada @SeleniumTest. Utilizamos essa anotação para configurar o tipo da classe WebDriver que será instanciada antes da execução de um método de teste (e para registrar automaticamente nossa extensão SeleniumTestExtension via @ExtendWith).

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

  • Ela possui um atributo driver que retorna o tipo do WebDriver (KClass<out WebDriver>) que será instanciado.
  • O valor padrão do atributo driver é ChromeDriver::class, o que significa que, por padrão, nossos testes End-to-End serão executados utilizando o navegador Google Chrome.
  • Ela já é meta-anotada com @ExtendWith(SeleniumTestExtension::class), eliminando a necessidade de declarar @ExtendWith repetidamente nas classes de teste.

O código-fonte da anotação SeleniumTest (em src/endToEndTest/kotlin/com/testwithspring/intermediate/SeleniumTest.kt) fica assim:

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

import org.junit.jupiter.api.extension.ExtendWith
import org.openqa.selenium.WebDriver
import org.openqa.selenium.chrome.ChromeDriver
import kotlin.reflect.KClass

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
@ExtendWith(SeleniumTestExtension::class)
@MustBeDocumented
annotation class SeleniumTest(
    val driver: KClass<out WebDriver> = ChromeDriver::class
)

Passo 2: Criando a Anotação @SeleniumWebDriver

Segundo, criamos uma anotação chamada @SeleniumWebDriver. Utilizamos essa anotação para identificar o campo (propriedade) da classe de teste no qual a extensão deverá injetar o objeto WebDriver recém-criado.

O código-fonte da anotação SeleniumWebDriver (em src/endToEndTest/kotlin/com/testwithspring/intermediate/SeleniumWebDriver.kt) fica assim:

1
2
3
4
5
6
package com.testwithspring.intermediate

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER)
@MustBeDocumented
annotation class SeleniumWebDriver

2. Implementando o Gerenciador de Ciclo de Vida do Browser

Depois de criarmos essas duas anotações, precisamos escrever o componente do JUnit que gerencia o ciclo de vida do nosso objeto WebDriver. No JUnit 4 clássico, estendíamos a classe BlockJUnit4ClassRunner (sobrescrevendo withBefores e withAfters); no JUnit 6, implementamos BeforeEachCallback (que executa antes de qualquer método @BeforeEach) e AfterEachCallback (que executa depois de qualquer método @AfterEach).

Podemos criar nossa extensão (e equivalente estrutural do SeleniumTestRunner) seguindo estes 11 passos:

  1. Criar a classe SeleniumTestExtension implementando BeforeEachCallback e AfterEachCallback (ou estendendo BlockJUnit4ClassRunner em suítes legadas com junit-vintage-engine).
  2. Validar a configuração da classe de teste antes de preparar a instância.
  3. Encontrar a anotação @SeleniumTest na classe de teste. Se essa anotação não for encontrada na classe de teste, lançamos uma IllegalStateException.
  4. Encontrar os campos da classe de teste que estão anotados com a anotação @SeleniumWebDriver.
  5. Garantir que a classe de teste possua exatamente um campo anotado com @SeleniumWebDriver. Se nenhum campo for encontrado, ou se mais de um campo for encontrado na classe de teste, lançamos uma IllegalStateException.
  6. Garantir que o campo anotado com @SeleniumWebDriver não seja private (ou torná-lo acessível via reflexão no Kotlin com isAccessible = true / validar visibilidade).
  7. Garantir que o tipo do campo anotado seja compatível com a interface WebDriver. Caso contrário, lançamos uma IllegalStateException.
  8. Obter o campo anotado da classe de teste para injeção.
  9. Criar um novo objeto WebDriver utilizando reflexão (getDeclaredConstructor().newInstance()) a partir da classe configurada em @SeleniumTest.
  10. Garantir que o objeto WebDriver seja criado e injetado no campo anotado da instância de teste antes que os métodos anotados com @BeforeEach sejam executados.
  11. Garantir que o navegador seja fechado (browser.quit()) depois que os métodos anotados com @AfterEach terminarem de executar.

Vamos analisar o código completo que implementa todos esses 11 passos 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
 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
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
package com.testwithspring.intermediate

import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.ExtensionContext.Namespace
import org.openqa.selenium.WebDriver
import java.lang.reflect.Field
import java.lang.reflect.Modifier

class SeleniumTestExtension : BeforeEachCallback, AfterEachCallback {

    companion object {
        private val NAMESPACE = Namespace.create(SeleniumTestExtension::class.java)
        private const val BROWSER_KEY = "webdriver_instance"
    }

    override fun beforeEach(context: ExtensionContext) {
        val testClass = context.requiredTestClass
        val testInstance = context.requiredTestInstance

        // Passos 2 a 7: Valida as anotações e o campo de destino na classe de teste
        validateClassMetadata(testClass)

        // Passo 8: Localiza o campo anotado com @SeleniumWebDriver
        val webDriverField = findWebDriverField(testClass)

        // Passo 9: Instancia o WebDriver configurado em @SeleniumTest
        val browser = createWebDriver(testClass)

        // Armazena no contexto do JUnit 6 para fechar após o @AfterEach
        context.getStore(NAMESPACE).put(BROWSER_KEY, browser)

        // Passo 10: Injeta o WebDriver na instância de teste ANTES dos métodos @BeforeEach
        webDriverField.isAccessible = true
        webDriverField.set(testInstance, browser)
    }

    override fun afterEach(context: ExtensionContext) {
        // Passo 11: Fecha o navegador DEPOIS que os métodos @AfterEach executaram
        val browser = context.getStore(NAMESPACE).remove(BROWSER_KEY, WebDriver::class.java)
        browser?.quit()
    }

    private fun validateClassMetadata(testClass: Class<*>) {
        // Passo 3: Busca a anotação @SeleniumTest
        testClass.getAnnotation(SeleniumTest::class.java)
            ?: throw IllegalStateException(
                "A anotação @SeleniumTest não foi encontrada na classe de teste: ${testClass.name}"
            )

        // Passo 4: Busca campos anotados com @SeleniumWebDriver
        val fields = testClass.declaredFields.filter {
            it.isAnnotationPresent(SeleniumWebDriver::class.java)
        }

        // Passo 5: Garante que existe exatamente 1 campo anotado
        if (fields.isEmpty()) {
            throw IllegalStateException(
                "Nenhum campo anotado com @SeleniumWebDriver foi encontrado na classe: ${testClass.name}"
            )
        }
        if (fields.size > 1) {
            throw IllegalStateException(
                "A classe de teste (${testClass.name}) não pode ter mais de um campo anotado com @SeleniumWebDriver"
            )
        }

        val field = fields.first()

        // Passo 6: Verifica se o campo não é privado (ou exige lateinit var / @JvmField)
        if (Modifier.isPrivate(field.modifiers) && !field.isAnnotationPresent(JvmField::class.java)) {
            // Em Kotlin, propriedades lateinit var geram um backing field privado com getter/setter públicos.
            // Verificamos se o setter público existe ou habilitamos acesso reflexivo seguro.
            val setterName = "set${field.name.replaceFirstChar { it.uppercase() }}"
            val hasPublicSetter = testClass.methods.any { it.name == setterName && Modifier.isPublic(it.modifiers) }
            if (!hasPublicSetter) {
                throw IllegalStateException(
                    "O campo (${field.name}) anotado com @SeleniumWebDriver não pode ser privado."
                )
            }
        }

        // Passo 7: Garante que o tipo do campo é WebDriver
        if (!WebDriver::class.java.isAssignableFrom(field.type)) {
            throw IllegalStateException(
                "O campo (${field.name}) anotado com @SeleniumWebDriver deve ser do tipo WebDriver."
            )
        }
    }

    private fun findWebDriverField(testClass: Class<*>): Field {
        return testClass.declaredFields.first {
            it.isAnnotationPresent(SeleniumWebDriver::class.java)
        }
    }

    private fun createWebDriver(testClass: Class<*>): WebDriver {
        try {
            val annotation = testClass.getAnnotation(SeleniumTest::class.java)
            return annotation.driver.java.getDeclaredConstructor().newInstance()
        } catch (ex: Exception) {
            throw RuntimeException("Falha ao instanciar o objeto WebDriver", ex)
        }
    }
}

Alternativa Moderna com Playwright: Se você estiver utilizando o Microsoft Playwright em vez do Selenium WebDriver na sua suíte E2E, a lógica da extensão acima permanece exatamente a mesma: basta injetar um BrowserContext e uma Page recém-criados em beforeEach() (a partir de uma instância compartilhada de Browser) e fechar o BrowserContext em afterEach(). Criar um BrowserContext isolado por teste no Playwright leva apenas alguns milissegundos e oferece o mesmo isolamento completo de cookies e localStorage de abrir e fechar um navegador inteiro!


3. Escrevendo Nossa Classe de Teste End-to-End

Agora podemos configurar nossas classes de teste End-to-End seguindo estes passos:

  1. Anotar a classe de teste com @SeleniumTest. Como o valor padrão do atributo driver é ChromeDriver::class, não precisamos configurar o tipo do driver manualmente se quisermos usar o Chrome.
  2. Anotar a classe de teste com @Category(EndToEndTest::class) (no JUnit 4) ou @Tag("endToEndTest") (no JUnit 6) quando estivermos filtrando por tags, ou simplesmente colocá-la no source set src/endToEndTest/kotlin do Gradle JVM Test Suite.
  3. Adicionar um campo WebDriver na classe de teste e anotá-lo com @SeleniumWebDriver.

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

import com.testwithspring.intermediate.EndToEndTest
import com.testwithspring.intermediate.SeleniumTest
import com.testwithspring.intermediate.SeleniumWebDriver
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.openqa.selenium.WebDriver

@SeleniumTest
@Tag("endToEndTest")
class EndToEndTestExample {

    @SeleniumWebDriver
    lateinit var browser: WebDriver

    @BeforeEach
    fun setUp() {
        // O objeto 'browser' já foi instanciado e injetado antes deste método!
    }

    @Test
    fun `deve renderizar a pagina inicial da aplicacao`() {
        // Podemos usar 'browser' diretamente no teste
    }
}

Resumo

Nesta aula, aprendemos que:

  • Se não quisermos gerenciar o ciclo de vida dos objetos WebDriver (ou Page do Playwright) manualmente em cada classe de teste, precisamos implementar uma extensão do JUnit que crie e feche automaticamente o navegador para cada método de teste.
  • Nossa extensão garante que a instância do navegador já esteja disponível dentro dos métodos anotados com @BeforeEach e só seja encerrada após a conclusão dos métodos anotados com @AfterEach.
  • Na próxima aula, veremos como configurar testes End-to-End para uma aplicação Spring Boot, aproveitando o @SpringBootTest e um TestExecutionListener customizado para iniciar a própria aplicação embarcada antes dos testes.