Integrando Servidor Spring Boot, Testcontainers e Playwright

Depois de concluirmos esta aula, nós:

  • Compreenderemos como podemos iniciar nossa aplicação Spring Boot 4 antes que nossos testes End-to-End sejam executados.
  • Saberemos como configurar nossos testes End-to-End combinando o SpringExtension e nossa PlaywrightExtension (ou TestExecutionListener).
  • Saberemos inicializar nosso banco de dados em um estado conhecido antes da execução de cada cenário End-to-End usando @Sql.

Esta aula assume que:

  • Você sabe executar seus testes End-to-End utilizando o Gradle 9 (endToEndTest).
  • Você está familiarizado com o Microsoft Playwright.
  • Você sabe configurar seus testes End-to-End utilizando JUnit 6.
  • Você está familiarizado com o Kotest Framework.

Nota importante: Você deve ter concluído as aulas Configurando Nossos Testes End-to-End com Spring Boot e Inicializando Nosso Banco de Dados com a Anotação @Sql antes de prosseguir com esta aula. O objetivo desta aula é demonstrar passo a passo como aplicar essas técnicas quando estamos escrevendo especificações End-to-End com o Kotest Framework.

Configurando Nossos Testes End-to-End no Spring Boot 4

Podemos configurar nossos testes End-to-End no Kotest seguindo sete passos progressivos:

Primeiro, temos que criar uma nova classe de especificação e garantir que seus cenários sejam executados quando rodarmos nossos testes End-to-End (@EndToEndTest). Após termos criado uma nova classe de especificação, seu código-fonte fica assim:

1
2
3
4
5
6
7
8
9
package com.testwithspring.master.e2e

import com.testwithspring.master.EndToEndTest
import io.kotest.core.spec.style.BehaviorSpec

@EndToEndTest
class ExampleSpec : BehaviorSpec({

})

Segundo, temos que carregar o ApplicationContext da nossa aplicação Spring Boot e importar a configuração do PostgreSQL via Testcontainers (@ServiceConnection). Também registramos a SpringExtension(SpringTestLifecycleMode.Root) para integrar os ciclos de vida do Spring TestContext e do Kotest. Após termos feito isso, o código-fonte da nossa classe de especificação fica assim:

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

import com.testwithspring.master.EndToEndTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.TaskTrackerApplication
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import

@SpringBootTest(classes = [TaskTrackerApplication::class])
@Import(PostgresTestcontainersConfiguration::class)
@EndToEndTest
class ExampleSpec : BehaviorSpec({

    extensions(SpringExtension(SpringTestLifecycleMode.Root))

})

Terceiro, temos que garantir que o servidor web embarcado da nossa aplicação Spring Boot seja iniciado quando seu contexto de aplicação for carregado. Fazemos isso configurando o atributo webEnvironment de @SpringBootTest para WebEnvironment.DEFINED_PORT (que usa a porta padrão 8080 configurada na aplicação) ou WebEnvironment.RANDOM_PORT (injetando @LocalServerPort).

Após termos feito as alterações necessárias na nossa classe de especificação, 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
22
23
package com.testwithspring.master.e2e

import com.testwithspring.master.EndToEndTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.TaskTrackerApplication
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment
import org.springframework.context.annotation.Import

@SpringBootTest(
    classes = [TaskTrackerApplication::class],
    webEnvironment = WebEnvironment.DEFINED_PORT
)
@Import(PostgresTestcontainersConfiguration::class)
@EndToEndTest
class ExampleSpec : BehaviorSpec({

    extensions(SpringExtension(SpringTestLifecycleMode.Root))

})

Quarto, temos que especificar o perfil do Spring utilizado quando os cenários da nossa classe de especificação são executados (Profiles.END_TO_END_TEST). Após termos feito isso, o código-fonte da nossa classe de especificação 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
package com.testwithspring.master.e2e

import com.testwithspring.master.EndToEndTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.TaskTrackerApplication
import com.testwithspring.master.config.Profiles
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles

@SpringBootTest(
    classes = [TaskTrackerApplication::class],
    webEnvironment = WebEnvironment.DEFINED_PORT
)
@Import(PostgresTestcontainersConfiguration::class)
@ActiveProfiles(Profiles.END_TO_END_TEST)
@EndToEndTest
class ExampleSpec : BehaviorSpec({

    extensions(SpringExtension(SpringTestLifecycleMode.Root))

})

Quinto, temos que configurar o Playwright utilizando a PlaywrightExtension que criamos na aula anterior (ou um TestExecutionListener customizado do Spring). Fazemos isso registrando a instância de PlaywrightExtension na lista de extensions(...) da especificação:

 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.master.e2e

import com.testwithspring.master.EndToEndTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.TaskTrackerApplication
import com.testwithspring.master.config.Profiles
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles

@SpringBootTest(
    classes = [TaskTrackerApplication::class],
    webEnvironment = WebEnvironment.DEFINED_PORT
)
@Import(PostgresTestcontainersConfiguration::class)
@ActiveProfiles(Profiles.END_TO_END_TEST)
@EndToEndTest
class ExampleSpec : BehaviorSpec({

    val playwright = PlaywrightExtension(
        browserOption = BrowserTypeOption.CHROMIUM,
        headless = true
    )

    extensions(
        SpringExtension(SpringTestLifecycleMode.Root),
        playwright
    )
})

Sexto, temos que garantir que os dados existentes sejam removidos do banco de dados após a execução de cada cenário raiz (ExecutionPhase.AFTER_TEST_METHOD). Após termos adicionado a anotação @Sql de limpeza à nossa classe de especificação, 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
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.testwithspring.master.e2e

import com.testwithspring.master.EndToEndTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.TaskTrackerApplication
import com.testwithspring.master.config.Profiles
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.context.jdbc.Sql.ExecutionPhase.AFTER_TEST_METHOD

@SpringBootTest(
    classes = [TaskTrackerApplication::class],
    webEnvironment = WebEnvironment.DEFINED_PORT
)
@Import(PostgresTestcontainersConfiguration::class)
@Sql(
    value = ["classpath:/com/testwithspring/master/cleandb.sql"],
    executionPhase = AFTER_TEST_METHOD
)
@ActiveProfiles(Profiles.END_TO_END_TEST)
@EndToEndTest
class ExampleSpec : BehaviorSpec({

    val playwright = PlaywrightExtension(
        browserOption = BrowserTypeOption.CHROMIUM,
        headless = true
    )

    extensions(
        SpringExtension(SpringTestLifecycleMode.Root),
        playwright
    )
})

Sétimo, temos que inicializar nosso banco de dados em um estado conhecido antes que cada cenário raiz seja executado (BEFORE_TEST_METHOD, que é a fase padrão de @Sql). Podemos fazer isso seguindo estes passos:

  1. Inserir os dados de usuários no nosso banco de dados (users.sql).
  2. Inserir os dados de tarefas no nosso banco de dados (tasks.sql).

Após termos feito todas as alterações necessárias na nossa classe de especificação, seu código-fonte completo 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
package com.testwithspring.master.e2e

import com.testwithspring.master.EndToEndTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.TaskTrackerApplication
import com.testwithspring.master.config.Profiles
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.context.jdbc.Sql.ExecutionPhase.AFTER_TEST_METHOD
import org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_METHOD

@SpringBootTest(
    classes = [TaskTrackerApplication::class],
    webEnvironment = WebEnvironment.DEFINED_PORT
)
@Import(PostgresTestcontainersConfiguration::class)
@Sql(
    value = [
        "classpath:/com/testwithspring/master/users.sql",
        "classpath:/com/testwithspring/master/tasks.sql"
    ],
    executionPhase = BEFORE_TEST_METHOD
)
@Sql(
    value = ["classpath:/com/testwithspring/master/cleandb.sql"],
    executionPhase = AFTER_TEST_METHOD
)
@ActiveProfiles(Profiles.END_TO_END_TEST)
@EndToEndTest
class ExampleSpec : BehaviorSpec({

    val playwright = PlaywrightExtension(
        browserOption = BrowserTypeOption.CHROMIUM,
        headless = true
    )

    extensions(
        SpringExtension(SpringTestLifecycleMode.Root),
        playwright
    )
})

Nota sobre Transações em Testes End-to-End:

Nunca anote uma especificação End-to-End com @Transactional! Como o servidor web HTTP do Spring Boot processa as requisições enviadas pelo navegador Playwright em threads separadas da thread do teste, uma transação aberta na thread do teste não seria visível para o servidor HTTP. Por isso, em testes End-to-End sempre usamos @Sql com BEFORE_TEST_METHOD e AFTER_TEST_METHOD (cleandb.sql), que fazem commit imediato no banco de dados.

Agora sabemos configurar nossos testes End-to-End integrando Spring Boot 4, Testcontainers, @Sql e Playwright no Kotest Framework. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou quatro coisas:

  • Podemos iniciar nossa aplicação Spring Boot configurando o atributo webEnvironment da anotação @SpringBootTest para WebEnvironment.DEFINED_PORT (ou RANDOM_PORT).
  • Combinamos SpringExtension(SpringTestLifecycleMode.Root) e PlaywrightExtension para orquestrar o servidor Spring Boot e o navegador Playwright na mesma especificação Kotest.
  • Podemos configurar o estado inicial da página ou autenticar o usuário no início do cenário e limpar a sessão ao final de cada teste raiz.
  • Podemos inicializar nosso banco de dados em um estado conhecido antes de cada cenário e limpá-lo após cada cenário anotando nossa classe de especificação com múltiplas anotações @Sql.