Customizando Conexões com @ServiceConnection e Testcontainers

Ao concluirmos esta aula, nós:

  • Saberemos como criar um carregador de datasets customizado (ReplacementDataSetLoader) que nos permite usar replacement datasets ([null] e [empty_string]).
  • Compreenderemos como alterar a configuração padrão do DbUnit através dos beans DatabaseConfigBean e DatabaseDataSourceConnectionFactoryBean.
  • Saberemos como provisionar um banco de dados PostgreSQL real para nossos testes de integração utilizando Testcontainers 2.0 e a anotação @ServiceConnection do Spring Boot 4.1.

Introdução à Anotação @DbUnitConfiguration

A anotação @DbUnitConfiguration nos permite customizar o comportamento do DbUnitTestExecutionListener. Ela possui quatro atributos principais:

  • databaseConnection: Define os nomes dos beans do Spring que fornecem conexões de banco de dados para o DbUnit (por padrão, procura dbUnitDatabaseConnection ou dataSource).
  • dataSetLoader: Define a classe DataSetLoader usada para carregar nossos datasets XML.
  • dataSetLoaderBean: Define o nome de um bean do Spring que implementa DataSetLoader.
  • databaseOperationLookup: Define a classe usada para resolver as operações de banco de dados do DbUnit.

Vamos começar criando nosso carregador customizado (ReplacementDataSetLoader) para suportar marcadores de substituição nos nossos datasets!


Usando Replacement Data Sets ([null] e [empty_string])

Por padrão, o FlatXmlDataSetLoader não substitui strings especiais como [null] por valores null reais no banco de dados. Para habilitarmos esse recurso (além do column sensing que já vem ativo no FlatXmlDataSetLoader), criamos a classe ReplacementDataSetLoader seguindo três passos:

Primeiro, criamos uma classe que estende FlatXmlDataSetLoader:

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

import com.github.springtestdbunit.dataset.FlatXmlDataSetLoader

class ReplacementDataSetLoader : FlatXmlDataSetLoader() {
}

Segundo, implementamos um método privado createReplacementDataSet() que envolve o IDataSet carregado em um ReplacementDataSet e configura dois placeholders:

  1. A string "[empty_string]" é substituída por uma string vazia ("").
  2. A string "[null]" é substituída pelo valor null.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package com.testwithspring.intermediate

import com.github.springtestdbunit.dataset.FlatXmlDataSetLoader
import org.dbunit.dataset.IDataSet
import org.dbunit.dataset.ReplacementDataSet

class ReplacementDataSetLoader : FlatXmlDataSetLoader() {

    private fun createReplacementDataSet(flatXmlDataSet: IDataSet): IDataSet {
        val replacementDataSet = ReplacementDataSet(flatXmlDataSet)

        replacementDataSet.addReplacementObject("[empty_string]", "")
        replacementDataSet.addReplacementObject("[null]", null)

        return replacementDataSet
    }
}

Terceiro, sobrescrevemos o método createDataSet(resource: Resource) chamando super.createDataSet(resource) e passando o resultado para createReplacementDataSet(...):

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

import com.github.springtestdbunit.dataset.FlatXmlDataSetLoader
import org.dbunit.dataset.IDataSet
import org.dbunit.dataset.ReplacementDataSet
import org.springframework.core.io.Resource

class ReplacementDataSetLoader : FlatXmlDataSetLoader() {

    override fun createDataSet(resource: Resource): IDataSet {
        val flatXmlDataSet = super.createDataSet(resource)
        return createReplacementDataSet(flatXmlDataSet)
    }

    private fun createReplacementDataSet(flatXmlDataSet: IDataSet): IDataSet {
        val replacementDataSet = ReplacementDataSet(flatXmlDataSet)

        replacementDataSet.addReplacementObject("[empty_string]", "")
        replacementDataSet.addReplacementObject("[null]", null)

        return replacementDataSet
    }
}

Agora configuramos nossa classe de teste de integração para usar o ReplacementDataSetLoader através da anotação @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@SpringBootTest(classes = [PersistenceContext::class])
@TestExecutionListeners(
    value = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class
    ]
)
@DatabaseSetup("messages.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@Tag("integrationTest")
class FindMessageByIdTest(
    @Autowired private val repository: MessageRepository
) {

    @Test
    fun `deve retornar Optional contendo a mensagem quando encontrada`() {
        val found = repository.findById(Messages.HelloWorld.ID)
        assertThat(found).isNotEmpty
    }
}

Contudo, se o nosso arquivo messages.xml tentar inserir uma string vazia (message_text="[empty_string]") no banco de dados:

1
2
3
4
5
6
<dataset>
    <messages id="1"
              message_text="Hello World!"/>
    <messages id="2"
              message_text="[empty_string]"/>
</dataset>

Ao executarmos o teste com a configuração padrão do DbUnit, receberemos a seguinte exceção:

1
2
3
java.lang.IllegalArgumentException: table.column=messages.MESSAGE_TEXT value
is empty but must contain a value (to disable this feature check, set
DatabaseConfig.FEATURE_ALLOW_EMPTY_FIELDS to true)

Vamos resolver esse problema customizando a configuração padrão do DbUnit no contexto de teste!


Modificando a Configuração Padrão do DbUnit (DatabaseConfigBean)

Para alterarmos a configuração interna do DbUnit (e também conectarmos um banco PostgreSQL real via Testcontainers!), criamos uma classe de configuração para os nossos testes de integração (IntegrationTestContext) seguindo três passos:

Primeiro, criamos a classe @TestConfiguration (ou @Configuration) IntegrationTestContext que importa a configuração da aplicação:

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

import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import

@Configuration
@Import(PersistenceContext::class)
class IntegrationTestContext {
}

Segundo, declaramos um bean do tipo DatabaseConfigBean e habilitamos a propriedade allowEmptyFields = true (permitindo que o DbUnit insira strings vazias "" em instruções INSERT):

1
2
3
4
5
6
    @Bean
    fun databaseConfigBean(): DatabaseConfigBean {
        return DatabaseConfigBean().apply {
            allowEmptyFields = true
        }
    }

Terceiro, declaramos o bean dbUnitDatabaseConnection do tipo DatabaseDataSourceConnectionFactoryBean, que recebe o DataSource do Spring e o DatabaseConfigBean recém-criado:

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

import com.github.springtestdbunit.bean.DatabaseConfigBean
import com.github.springtestdbunit.bean.DatabaseDataSourceConnectionFactoryBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import javax.sql.DataSource

@Configuration
@Import(PersistenceContext::class)
class IntegrationTestContext {

    @Bean
    fun databaseConfigBean(): DatabaseConfigBean {
        return DatabaseConfigBean().apply {
            allowEmptyFields = true
        }
    }

    @Bean
    fun dbUnitDatabaseConnection(
        dataSource: DataSource,
        databaseConfigBean: DatabaseConfigBean
    ): DatabaseDataSourceConnectionFactoryBean {
        return DatabaseDataSourceConnectionFactoryBean().apply {
            setDataSource(dataSource)
            setDatabaseConfig(databaseConfigBean)
        }
    }
}

Como nomeamos o método @Bean exatamente como dbUnitDatabaseConnection, o DbUnitTestExecutionListener o detecta automaticamente sem precisarmos alterar a anotação @DbUnitConfiguration!


Conectando um PostgreSQL Real com Testcontainers 2.0 e @ServiceConnection

No passado, muitos testes de integração usavam bancos em memória como H2 porque subir um PostgreSQL local exigia instalação manual. O problema do H2 é que ele não suporta recursos específicos do PostgreSQL (como tipos JSONB, arrays TEXT[], ILIKE ou sintaxes específicas de sequences).

No Spring Boot 4.1 com Testcontainers 2.0.5, podemos declarar um container PostgreSQLContainer como um @Bean anotado com @ServiceConnection dentro da nossa IntegrationTestContext:

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

import com.github.springtestdbunit.bean.DatabaseConfigBean
import com.github.springtestdbunit.bean.DatabaseDataSourceConnectionFactoryBean
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.context.annotation.Bean
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.utility.DockerImageName
import javax.sql.DataSource

@TestConfiguration(proxyBeanMethods = false)
class IntegrationTestContext {

    @Bean
    @ServiceConnection
    fun postgresContainer(): PostgreSQLContainer<*> {
        return PostgreSQLContainer(DockerImageName.parse("postgres:17-alpine"))
    }

    @Bean
    fun databaseConfigBean(): DatabaseConfigBean {
        return DatabaseConfigBean().apply {
            allowEmptyFields = true
        }
    }

    @Bean
    fun dbUnitDatabaseConnection(
        dataSource: DataSource,
        databaseConfigBean: DatabaseConfigBean
    ): DatabaseDataSourceConnectionFactoryBean {
        return DatabaseDataSourceConnectionFactoryBean().apply {
            setDataSource(dataSource)
            setDatabaseConfig(databaseConfigBean)
        }
    }
}

O que a anotação @ServiceConnection faz automaticamente?

  • Ela inicia o container Docker postgres:17-alpine antes do DataSource ser criado.
  • Ela descobre dinamicamente a porta mapeada, a URL JDBC, o usuário e a senha do container e autoconfigura o DataSource do Spring Boot sem que você precise escrever @DynamicPropertySource ou configurar URLs JDBC em arquivos .properties!
  • E como o dbUnitDatabaseConnection recebe esse mesmo dataSource, tanto a sua aplicação quanto o DbUnit (e o @Sql) conversam com o mesmo banco PostgreSQL real!

Agora basta referenciarmos IntegrationTestContext::class na nossa classe de teste:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@SpringBootTest(classes = [IntegrationTestContext::class])
@TestExecutionListeners(
    value = [DbUnitTestExecutionListener::class],
    mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
@DatabaseSetup("messages.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@Tag("integrationTest")
class FindMessageByIdTest(
    @Autowired private val repository: MessageRepository
) {

    @Test
    fun `deve retornar Optional contendo a mensagem quando encontrada`() {
        val found = repository.findById(Messages.HelloWorld.ID)
        assertThat(found).isNotEmpty
    }
}

Resumo

Esta aula nos ensinou quatro conceitos fundamentais:

  • A anotação @DbUnitConfiguration permite customizar o DbUnitTestExecutionListener.
  • Para habilitar replacement datasets ([null], [empty_string]), estendemos FlatXmlDataSetLoader e transformamos o IDataSet carregado em um ReplacementDataSet.
  • Para alterar propriedades internas do DbUnit (como allowEmptyFields = true), declaramos os beans DatabaseConfigBean e dbUnitDatabaseConnection (DatabaseDataSourceConnectionFactoryBean).
  • No Spring Boot 4.1 com Testcontainers 2.0, a anotação @ServiceConnection em um método @Bean que retorna PostgreSQLContainer conecta automaticamente o DataSource de teste ao container Docker real.