Integrando Testcontainers 2.0 com JUnit 6

Ao concluirmos este webinar, nós:

  • Compreenderemos como podemos integrar o Testcontainers 2.0.5 com o JUnit Jupiter 6.0.3.
  • Saberemos como criar uma extensão customizada do JUnit 6 que integra o Testcontainers ao ciclo de vida do JUnit Jupiter.
  • Conseguiremos utilizar nossa extensão customizada (e também o suporte nativo @Testcontainers / @Container) ao escrevermos testes com JUnit 6 e Kotlin 2.4.21.

Este webinar assume que:

Obtenha o código-fonte no GitHub

Utilizando a Abordagem Manual

Como nos lembramos da aula anterior, quando utilizávamos o Testcontainers com o antigo JUnit 4, podíamos iniciar e parar nossos contêineres Docker por meio de Rules do JUnit 4 (@ClassRule / @Rule). Quando utilizamos o Testcontainers com o JUnit Jupiter 6, podemos iniciar e parar nossos contêineres Docker manualmente utilizando métodos de setup (@BeforeAll) e teardown (@AfterAll).

Nota sobre o Testcontainers 2.0.5: O Testcontainers 2.0 conta com suporte integrado oficial para o JUnit Jupiter por meio do módulo org.testcontainers:testcontainers-junit-jupiter:2.0.5 (anotações @Testcontainers e @Container). Ainda assim, entender o controle manual e construir nossa própria extensão JUnit 6 passo a passo revela exatamente como o modelo de extensões do JUnit Jupiter opera sob o capô.

Vamos escrever uma classe de teste que utiliza um contêiner Docker executando um banco de dados PostgreSQL. Podemos escrever essa classe de teste seguindo estes passos:

Primeiro, precisamos adicionar uma propriedade private e static (dentro do companion object com @JvmStatic em Kotlin) do tipo PostgreSQLContainer à nossa classe de teste e garantir que essa propriedade contenha uma referência para uma nova instância de PostgreSQLContainer.

Após adicionarmos essa propriedade à nossa classe de teste, seu código-fonte fica da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import org.junit.jupiter.api.DisplayName
import org.testcontainers.containers.PostgreSQLContainer

@DisplayName("Run a PostgreSQL container")
class PostgreSQLContainerTest {

    companion object {
        @JvmStatic
        private val postgreSQL = PostgreSQLContainer("postgres:17-alpine")
    }
}

Segundo, precisamos adicionar um método de configuração (setup) à nossa classe de teste e garantir que esse método seja executado antes que qualquer método de teste seja rodado (@BeforeAll). Após adicionarmos esse método à nossa classe de teste, devemos implementá-lo iniciando o nosso contêiner Docker.

Depois de escrevermos esse método de setup, o código-fonte da nossa classe de teste fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.DisplayName
import org.testcontainers.containers.PostgreSQLContainer

@DisplayName("Run a PostgreSQL container")
class PostgreSQLContainerTest {

    companion object {
        @JvmStatic
        private val postgreSQL = PostgreSQLContainer("postgres:17-alpine")

        @BeforeAll
        @JvmStatic
        fun startDatabase() {
            postgreSQL.start()
        }
    }
}

Terceiro, precisamos escrever o método de teste que garante que o banco de dados PostgreSQL foi iniciado com sucesso. Após escrevermos esse método de teste, o código-fonte da nossa classe de teste fica da seguinte forma:

 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
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.testcontainers.containers.PostgreSQLContainer
import java.sql.DriverManager

@DisplayName("Run a PostgreSQL container")
class PostgreSQLContainerTest {

    companion object {
        @JvmStatic
        private val postgreSQL = PostgreSQLContainer("postgres:17-alpine")

        @BeforeAll
        @JvmStatic
        fun startDatabase() {
            postgreSQL.start()
        }
    }

    @Test
    @DisplayName("Should run a PostgreSQL container")
    fun shouldRunPostgreSQLContainer() {
        DriverManager.getConnection(
            postgreSQL.jdbcUrl,
            postgreSQL.username,
            postgreSQL.password
        ).use { conn ->
            conn.createStatement().use { statement ->
                statement.executeQuery("SELECT 42").use { resultSet ->
                    resultSet.next()

                    val result = resultSet.getInt(1)
                    assertThat(result).isEqualByComparingTo(42)
                }
            }
        }
    }
}

Quarto, precisamos adicionar um método de finalização (teardown) à nossa classe de teste e garantir que esse método seja executado após todos os métodos de teste terem sido executados (@AfterAll). Depois de adicionarmos esse método à nossa classe de teste, devemos implementá-lo parando o nosso contêiner Docker.

Após escrevermos esse método de teardown, o código-fonte da nossa classe de teste 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
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.testcontainers.containers.PostgreSQLContainer
import java.sql.DriverManager

@DisplayName("Run a PostgreSQL container")
class PostgreSQLContainerTest {

    companion object {
        @JvmStatic
        private val postgreSQL = PostgreSQLContainer("postgres:17-alpine")

        @BeforeAll
        @JvmStatic
        fun startDatabase() {
            postgreSQL.start()
        }

        @AfterAll
        @JvmStatic
        fun stopDatabase() {
            postgreSQL.stop()
        }
    }

    @Test
    @DisplayName("Should run a PostgreSQL container")
    fun shouldRunPostgreSQLContainer() {
        DriverManager.getConnection(
            postgreSQL.jdbcUrl,
            postgreSQL.username,
            postgreSQL.password
        ).use { conn ->
            conn.createStatement().use { statement ->
                statement.executeQuery("SELECT 42").use { resultSet ->
                    resultSet.next()

                    val result = resultSet.getInt(1)
                    assertThat(result).isEqualByComparingTo(42)
                }
            }
        }
    }
}

Quando executamos o nosso teste, vemos que ele passa com sucesso.

Mesmo que este exemplo utilize um contêiner Docker que inicia um banco de dados PostgreSQL, o processo é exatamente o mesmo para todos os tipos de contêineres suportados pelo Testcontainers.

Leitura Adicional:

Agora já conseguimos iniciar e parar nosso contêiner Docker utilizando a abordagem manual. A desvantagem dessa abordagem é que precisamos adicionar código duplicado de ciclo de vida (@BeforeAll e @AfterAll) em nossas classes de teste. A seguir, descobriremos como podemos eliminar esse código repetitivo escrevendo uma extensão customizada do JUnit 6.

Escrevendo uma Extensão Customizada do JUnit 6

Se quisermos nos livrar do código duplicado, podemos escrever uma extensão customizada do JUnit Jupiter 6 que inicia e para o nosso contêiner Docker automaticamente. Os requisitos da nossa extensão customizada são:

  • Ela deve obter o objeto do contêiner Docker a partir de um campo private e static encontrado na classe de teste que utiliza nossa extensão customizada.
  • Ela deve iniciar o nosso contêiner Docker antes que qualquer método de teste seja executado.
  • Ela deve parar o nosso contêiner Docker depois que todos os métodos de teste tiverem sido executados.

Podemos escrever essa extensão seguindo estes passos:

Primeiro, devemos criar uma nova classe que implementa as interfaces BeforeAllCallback e AfterAllCallback do JUnit Jupiter.

O código-fonte da classe criada fica da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import org.junit.jupiter.api.extension.AfterAllCallback
import org.junit.jupiter.api.extension.BeforeAllCallback
import org.junit.jupiter.api.extension.ExtensionContext

class TestContainersExtension : BeforeAllCallback, AfterAllCallback {

    override fun afterAll(extensionContext: ExtensionContext) {
    }

    override fun beforeAll(extensionContext: ExtensionContext) {
    }
}

Segundo, precisamos escrever um método private fun getContainer() que retorna uma referência para o objeto GenericContainer<*> declarado na classe de teste. Esse método recebe um objeto Class<*> como parâmetro e localiza o objeto GenericContainer<*> necessário utilizando reflexão (Reflection).

Após implementarmos esse método e o método auxiliar isContainerField(), o código-fonte da nossa classe de extensã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
27
28
29
30
31
32
33
34
import org.junit.jupiter.api.extension.AfterAllCallback
import org.junit.jupiter.api.extension.BeforeAllCallback
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.platform.commons.PreconditionViolationException
import org.testcontainers.containers.GenericContainer
import java.lang.reflect.Field
import java.lang.reflect.Modifier

class TestContainersExtension : BeforeAllCallback, AfterAllCallback {

    override fun afterAll(extensionContext: ExtensionContext) {
    }

    override fun beforeAll(extensionContext: ExtensionContext) {
    }

    private fun getContainer(testClass: Class<*>): GenericContainer<*> {
        val testContainerField = testClass.declaredFields
            .firstOrNull { isContainerField(it) }
            ?: throw PreconditionViolationException(
                "Could not find static GenericContainer field from test class: ${testClass.name}"
            )

        testContainerField.isAccessible = true

        // Podemos passar null para get() porque o campo deve ser estático (static)
        return testContainerField.get(null) as GenericContainer<*>
    }

    private fun isContainerField(field: Field): Boolean {
        return Modifier.isStatic(field.modifiers) &&
            GenericContainer::class.java.isAssignableFrom(field.type)
    }
}

Terceiro, precisamos iniciar nosso contêiner Docker antes que qualquer método de teste seja executado (beforeAll) e pará-lo depois que todos os métodos de teste tiverem sido executados (afterAll). Após fazermos isso, o código-fonte completo da nossa classe de extensão fica da seguinte forma:

 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
import org.junit.jupiter.api.extension.AfterAllCallback
import org.junit.jupiter.api.extension.BeforeAllCallback
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.platform.commons.PreconditionViolationException
import org.testcontainers.containers.GenericContainer
import java.lang.reflect.Field
import java.lang.reflect.Modifier

class TestContainersExtension : BeforeAllCallback, AfterAllCallback {

    override fun afterAll(extensionContext: ExtensionContext) {
        val container = getContainer(extensionContext.requiredTestClass)
        container.stop()
    }

    override fun beforeAll(extensionContext: ExtensionContext) {
        val container = getContainer(extensionContext.requiredTestClass)
        container.start()
    }

    private fun getContainer(testClass: Class<*>): GenericContainer<*> {
        val testContainerField = testClass.declaredFields
            .firstOrNull { isContainerField(it) }
            ?: throw PreconditionViolationException(
                "Could not find static GenericContainer field from test class: ${testClass.name}"
            )

        testContainerField.isAccessible = true

        // Podemos passar null para get() porque o campo deve ser estático (static)
        return testContainerField.get(null) as GenericContainer<*>
    }

    private fun isContainerField(field: Field): Boolean {
        return Modifier.isStatic(field.modifiers) &&
            GenericContainer::class.java.isAssignableFrom(field.type)
    }
}

Agora escrevemos uma extensão customizada do JUnit 6 capaz de iniciar e parar nosso contêiner Docker automaticamente. Vamos avançar e descobrir como podemos utilizar nossa nova extensão customizada.

Utilizando Nossa Extensão Customizada

Quando quisermos escrever uma classe de teste que utiliza um contêiner Docker executando um banco de dados PostgreSQL gerenciado pela nossa extensão, devemos seguir estes passos:

Primeiro, precisamos registrar nossa extensão customizada. Podemos fazer isso anotando nossa classe de teste com a anotação @ExtendWith.

Após registrarmos nossa extensão customizada, o código-fonte da nossa classe de teste fica da seguinte forma:

1
2
3
4
5
6
7
8
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(TestContainersExtension::class)
@DisplayName("Run a PostgreSQL container")
class PostgreSQLContainerExtensionTest {

}

Segundo, precisamos adicionar uma propriedade private e static (@JvmStatic no companion object) do tipo PostgreSQLContainer à nossa classe de teste e garantir que essa propriedade contenha uma referência para um novo objeto PostgreSQLContainer.

Após adicionarmos essa propriedade à nossa classe de teste, seu código-fonte fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.extension.ExtendWith
import org.testcontainers.containers.PostgreSQLContainer

@ExtendWith(TestContainersExtension::class)
@DisplayName("Run a PostgreSQL container")
class PostgreSQLContainerExtensionTest {

    companion object {
        @JvmStatic
        private val postgreSQL = PostgreSQLContainer("postgres:17-alpine")
    }
}

Terceiro, precisamos escrever o método de teste que garante que o banco de dados PostgreSQL foi iniciado com sucesso. Após escrevermos esse método de teste, o código-fonte da nossa classe de teste fica da seguinte forma:

 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
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.testcontainers.containers.PostgreSQLContainer
import java.sql.DriverManager

@ExtendWith(TestContainersExtension::class)
@DisplayName("Run a PostgreSQL container")
class PostgreSQLContainerExtensionTest {

    companion object {
        @JvmStatic
        private val postgreSQL = PostgreSQLContainer("postgres:17-alpine")
    }

    @Test
    @DisplayName("Should run a PostgreSQL container")
    fun shouldRunPostgreSQLContainer() {
        DriverManager.getConnection(
            postgreSQL.jdbcUrl,
            postgreSQL.username,
            postgreSQL.password
        ).use { conn ->
            conn.createStatement().use { statement ->
                statement.executeQuery("SELECT 42").use { resultSet ->
                    resultSet.next()

                    val result = resultSet.getInt(1)
                    assertThat(result).isEqualByComparingTo(42)
                }
            }
        }
    }
}

Quando executamos o nosso teste, vemos que ele passa.

Novamente, mesmo que este exemplo utilize um contêiner Docker que inicia um banco de dados PostgreSQL, o processo é exatamente o mesmo para todos os tipos de contêineres suportados.

Dica na Stack Moderna (Testcontainers 2.0.5): Na prática cotidiana com Testcontainers 2.0.5 e JUnit Jupiter 6.0.3, a extensão oficial @Testcontainers e a anotação @Container (do artefato org.testcontainers:testcontainers-junit-jupiter:2.0.5) implementam exatamente esse mecanismo de BeforeAllCallback / AfterAllCallback (além de BeforeEachCallback / AfterEachCallback para campos de instância) que acabamos de construir!

Já sabemos como iniciar e parar contêineres Docker com o JUnit 6. Vamos resumir o que aprendemos neste webinar.

Resumo

Este webinar nos ensinou duas coisas fundamentais:

  • Podemos iniciar e parar nosso contêiner Docker manualmente utilizando métodos de configuração (@BeforeAll) e finalização (@AfterAll).
  • Se quisermos remover código duplicado das nossas classes de teste, podemos implementar uma extensão customizada do JUnit 6 (BeforeAllCallback e AfterAllCallback) — ou utilizar a extensão oficial @Testcontainers do Testcontainers 2.0 — que inicia e para nosso contêiner Docker automaticamente.

Obtenha o código-fonte no GitHub