Testando Colunas TEXT[] (Arrays PostgreSQL) em Testes

Ao finalizarmos esta aula, seremos capazes de:

  • Escrever um tipo de dado customizado (custom DbUnit data type) que ofereça suporte nativo a arrays de texto (text[]) do PostgreSQL.
  • Compreender e configurar os mecanismos necessários para garantir que o DbUnit utilize nossa fábrica e nosso tipo de dados customizado durante a carga de data sets.

Esta aula assume que:

Obtenha o código-fonte no GitHub


O Desafio: Arrays Nativos no PostgreSQL (text[]) e o DbUnit

O PostgreSQL possui suporte de primeira classe a tipos de array multidimensionais, como text[], varchar[], integer[] e bigint[]. No desenvolvimento de aplicações modernas com Spring Boot 4.1.2, Kotlin 2.4.21 e Hibernate 7 ou jOOQ, o uso de arrays nativos é comum para armazenar listas simples (como tags de produtos, permissões ou categorias) sem a necessidade de criar tabelas associativas adicionais.

No entanto, ao escrever testes de integração com o DbUnit para verificar repositórios que persistem ou consultam essas tabelas, nos deparamos com uma limitação: a implementação padrão PostgresqlDataTypeFactory fornecida pelo DbUnit não sabe como converter uma string declarada em um arquivo XML ou JSON de data set em um array JDBC compatível (java.sql.Array ou Array<String>).

Se tentarmos carregar um valor comum em uma coluna tags do tipo text[], o driver JDBC do PostgreSQL lançará uma exceção semelhante a:

1
2
org.postgresql.util.PSQLException: ERROR: column "tags" is of type text[] but expression is of type character varying
  Dica: You will need to rewrite or cast the expression.

Para contornar essa situação e manter nossos data sets limpos e declarativos, ensinaremos o DbUnit a converter automaticamente uma lista delimitada por vírgulas em um array nativo de strings antes de enviar a instrução ao PostgreSQL.


Criando Nosso Conjunto de Dados do DbUnit

Precisamos escrever testes de integração para um repositório que lê e manipula informações da tabela products. Quando inicializamos nosso banco de dados em um estado conhecido antes da execução de cada teste, devemos definir o valor da coluna tags, que armazena um array de texto (text[]).

Ao configurar o valor dessa coluna em nosso data set do DbUnit, a escolha mais intuitiva, legível e sucinta é utilizar uma sequência de valores separados por vírgula (comma-separated value) especificando as tags de cada produto:

1
2
3
4
5
6
7
<dataset>
    <products id="1"
              product_description="Product description"
              product_name="Product name"
              tags="example,product"
    />
</dataset>

A seguir, aprenderemos como criar um tipo de dado customizado no DbUnit que reconheça essa formatação e faça o casting adequado.


Escrevendo Nosso Tipo de Dados Customizado do DbUnit

Quando queremos criar um tipo de dado customizado no DbUnit, devemos criar uma nova classe estendendo AbstractDataType e sobrescrever o método typeCast() da classe DataType.

Podemos implementar esse método seguindo estes dois passos:

  1. Se o valor fornecido como parâmetro do método for um objeto String, transformamos esse valor em um array de strings (Array<String> / String[]) e retornamos o array criado.
  2. Se o valor fornecido como parâmetro do método não for um objeto String, lançamos uma nova exceção informando que o tipo recebido não é suportado.

Observe também o construtor da nossa classe: invocamos o construtor da superclasse AbstractDataType passando o nome do tipo SQL, o código numérico JDBC (Types.ARRAY), a classe Java correspondente (String::class.java ou Array<String>::class.java) e a flag booleana false (indicando que não se trata de um número).

Após implementarmos a classe StringArrayDataType em Kotlin, 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
package com.testwithspring.master.dbunitdatatypes

import org.dbunit.dataset.datatype.AbstractDataType
import org.dbunit.dataset.datatype.TypeCastException

class StringArrayDataType(
    name: String,
    sqlType: Int
) : AbstractDataType(name, sqlType, String::class.java, false) {

    @Throws(TypeCastException::class)
    override fun typeCast(value: Any?): Any? {
        if (value == null) {
            return null
        }

        if (value is String) {
            return value.split(",").toTypedArray()
        }

        if (value is Array<*>) {
            return value
        }

        throw IllegalArgumentException(
            "Unsupported type. Expected String but was: ${value.javaClass.name}"
        )
    }
}

Como o Driver JDBC do PostgreSQL processa esse retorno: O driver JDBC oficial do PostgreSQL (org.postgresql.Driver) aceita diretamente instâncias de String[] / Array<String> no método PreparedStatement.setObject(index, value). Ao converter a string do XML em um toTypedArray(), o DbUnit delega o array nativo de strings para o driver, que mapeia perfeitamente a coleção para o tipo text[] do PostgreSQL.

Com o tipo de dado criado, precisamos agora garantir que o DbUnit o utilize ao processar as colunas de tabela do tipo array.


Habilitando Nosso Tipo de Dados Customizado no DbUnit

Para garantir que o DbUnit utilize nosso tipo de dados customizado, precisamos criar uma nova classe de fábrica de tipos de dados (data type factory) estendendo a fábrica padrão do PostgreSQL (PostgresqlDataTypeFactory) e sobrescrever o método createDataType() da interface IDataTypeFactory.

Podemos implementar o método createDataType() seguindo estes três passos:

  1. Se o tipo JDBC da coluna processada for um array (sqlType == Types.ARRAY), verificamos se o nome do tipo SQL coincide com o identificador interno do PostgreSQL para arrays de texto (representado pela constante literal "_text"). Caso afirmativo, retornamos uma nova instância de StringArrayDataType.
  2. Se o tipo JDBC da coluna for um array, mas com um tipo não suportado por nossa fábrica, lançamos uma exceção IllegalArgumentException informando o nome do tipo rejeitado.
  3. Se a coluna não for do tipo array, invocamos o método createDataType() da superclasse (PostgresqlDataTypeFactory) e retornamos o tipo de dado padrão resolvido por ela. Isso garante que todos os demais tipos primitivos e estruturas padrão do PostgreSQL continuem funcionando sem nenhuma regressão.

Após implementarmos o método createDataType(), o código-fonte da nossa classe de fábrica customizada 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
package com.testwithspring.master.dbunitdatatypes

import org.dbunit.dataset.datatype.DataType
import org.dbunit.dataset.datatype.DataTypeException
import org.dbunit.ext.postgresql.PostgresqlDataTypeFactory
import java.sql.Types

class CustomPostgreSQLDataTypeFactory : PostgresqlDataTypeFactory() {

    override fun createDataType(
        sqlType: Int,
        sqlTypeName: String,
        tableName: String,
        columnName: String
    ): DataType {
        if (sqlType == Types.ARRAY) {
            if (sqlTypeName == SQL_TYPE_NAME_TEXT_ARRAY) {
                return StringArrayDataType(sqlTypeName, sqlType)
            }
            throw IllegalArgumentException("Unsupported array type: $sqlTypeName")
        }

        return super.createDataType(
            sqlType,
            sqlTypeName,
            tableName,
            columnName
        )
    }

    companion object {
        private const val SQL_TYPE_NAME_TEXT_ARRAY = "_text"
    }
}

Por que o nome do tipo é _text? No catálogo interno do PostgreSQL (pg_type), os tipos de array nativos recebem como convenção de nomenclatura o prefixo sublinhado seguido do tipo de elemento base (por exemplo, _text para text[], _int4 para integer[] e _varchar para varchar[]). Quando o driver JDBC inspeciona os metadados do banco (ResultSetMetaData.getColumnTypeName), ele retorna o identificador interno _text.

A seguir, veremos como registrar essa fábrica no contexto de testes do Spring Boot 4.1.2 e utilizá-la em um teste de integração completo.


Configuração do DbUnit no Spring Boot 4.1.2 e Testcontainers

Para que os testes de integração carreguem nossa fábrica customizada, definimos uma classe de configuração do Spring com anotação @Configuration (ou @TestConfiguration), instanciando os beans de configuração do DbUnit:

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

import com.github.springtestdbunit.bean.DatabaseConfigBean
import com.github.springtestdbunit.bean.DatabaseDataSourceConnectionFactoryBean
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import javax.sql.DataSource

@TestConfiguration
class TestDatabaseConfiguration {

    @Bean
    fun dbUnitDatabaseConfig(): DatabaseConfigBean {
        val config = DatabaseConfigBean()
        config.datatypeFactory = CustomPostgreSQLDataTypeFactory()
        return config
    }

    @Bean
    fun dbUnitDatabaseConnection(
        dataSource: DataSource,
        dbConfig: DatabaseConfigBean
    ): DatabaseDataSourceConnectionFactoryBean {
        val factoryBean = DatabaseDataSourceConnectionFactoryBean()
        factoryBean.setDataSource(dataSource)
        factoryBean.setDatabaseConfig(dbConfig)
        return factoryBean
    }
}

Anotação Customizada de Teste de Integração

Combinamos a configuração de banco de dados, o ouvinte de execução do DbUnit (DbUnitTestExecutionListener) e o perfil de integração em uma meta-anotação reutilizável:

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

import com.github.springtestdbunit.DbUnitTestExecutionListener
import org.junit.jupiter.api.Tag
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestExecutionListeners

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@SpringBootTest
@Import(TestDatabaseConfiguration::class)
@TestExecutionListeners(
    listeners = [DbUnitTestExecutionListener::class],
    mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
@ActiveProfiles("integrationTest")
@Tag("integrationTest")
annotation class DatabaseIntegrationTest

Exemplo Prático: Repositório e Teste de Integração com JUnit Jupiter 6.0.3

Vejamos como o modelo de entidade e o teste de repositório se comportam na prática.

Entidade Product

No Spring Data / Hibernate 7 com PostgreSQL, podemos mapear arrays utilizando anotações nativas ou conversores de tipo:

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

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.hibernate.annotations.Array

@Entity
@Table(name = "products")
class Product(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    @Column(name = "product_name", nullable = false)
    var productName: String,

    @Column(name = "product_description", nullable = false)
    var productDescription: String,

    @Column(name = "tags", columnDefinition = "text[]")
    var tags: Array<String> = emptyArray()
)

Repositório Spring Data JPA

1
2
3
4
5
6
7
package com.testwithspring.master.dbunitdatatypes

import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface ProductRepository : JpaRepository<Product, Long>

Teste de Integração de Repositório

Agora, escrevemos o teste de integração utilizando JUnit Jupiter 6.0.3, AssertJ 3.27.7 e o conjunto de dados XML que criamos:

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

import com.github.springtestdbunit.annotation.DatabaseSetup
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired

@DatabaseIntegrationTest
@DatabaseSetup("/datasets/products.xml")
@DisplayName("Testes de integração para leitura de produtos com tags TEXT[]")
class ProductRepositoryTest(
    @Autowired private val productRepository: ProductRepository
) {

    @Test
    @DisplayName("Deve carregar produto com tags populadas a partir do dataset XML")
    fun shouldLoadProductWithTagsFromDataSet() {
        val produtoOpt = productRepository.findById(1L)

        assertThat(produtoOpt).isPresent
        val produto = produtoOpt.get()

        assertThat(produto.id).isEqualTo(1L)
        assertThat(produto.productName).isEqualTo("Product name")
        assertThat(produto.productDescription).isEqualTo("Product description")
        assertThat(produto.tags).containsExactly("example", "product")
    }
}

Quando o método shouldLoadProductWithTagsFromDataSet() é executado:

  1. O Spring TestContext inicializa o contêiner PostgreSQL via Testcontainers.
  2. O DbUnitTestExecutionListener intercepta a anotação @DatabaseSetup("/datasets/products.xml").
  3. O DbUnit utiliza o bean dbUnitDatabaseConfig, que referencia nossa CustomPostgreSQLDataTypeFactory.
  4. Ao encontrar a coluna tags com metadado SQL _text, a fábrica instancia StringArrayDataType.
  5. O método typeCast("example,product") processa a string e devolve arrayOf("example", "product").
  6. O driver JDBC do PostgreSQL recebe o array e popula a coluna text[] com perfeição.
  7. O teste executa productRepository.findById(1L) e valida os valores com asserções limpas e expressivas.

Resumo

Nesta aula, aprendemos:

  • Ao definir o valor de uma coluna de banco de dados que contém um array de texto (text[]), a maneira mais concisa e legível é utilizar valores separados por vírgula em nosso data set XML do DbUnit.
  • Para habilitar o DbUnit a entender essa notação, criamos a classe StringArrayDataType, herdando de AbstractDataType e implementando o método typeCast(), transformando a String de entrada em um array tipado (Array<String>).
  • Para instruir o DbUnit a utilizar nosso tipo customizado, criamos a fábrica CustomPostgreSQLDataTypeFactory, herdando de PostgresqlDataTypeFactory e sobrescrevendo createDataType().
  • Se o tipo JDBC for Types.ARRAY e o nome SQL for _text, retornamos uma nova instância de StringArrayDataType. Para tipos de array não suportados, lançamos uma exceção; para todos os demais tipos de coluna, delegamos a chamada para o método da superclasse.
  • Configuramos a fábrica customizada em nosso contexto de teste do Spring Boot 4.1.2 por meio dos beans DatabaseConfigBean e DatabaseDataSourceConnectionFactoryBean.

Obtenha o código-fonte no GitHub