Testando Colunas JSONB do PostgreSQL com Jackson e Kotlin

Ao finalizarmos esta aula, seremos capazes de:

  • Escrever um tipo de dado customizado no DbUnit que forneça suporte integral aos tipos de dados json e jsonb do banco de dados PostgreSQL.
  • Garantir que o DbUnit utilize a nossa fábrica de tipos de dados customizada durante o carregamento de datasets.
  • Mapear e testar entidades de domínio em Kotlin integradas ao Jackson Kotlin Module para persistência e consulta de documentos JSONB no Spring Boot 4.1.2.

Esta aula assume que:

  • Você está familiarizado com o DbUnit e seu mecanismo de gerenciamento de datasets em testes de integração.
  • Você sabe configurar testes de integração utilizando o ecossistema Spring Boot com banco de dados real.
  • Você compreende a implementação básica de tipos de dados customizados no DbUnit (conforme abordado na aula de fundamentos desta série).

1. Configuração do Projeto e Dependências (Gradle Kotlin DSL)

Para integrar testes de banco de dados com tipos nativos json/jsonb do PostgreSQL, precisamos do driver JDBC do PostgreSQL, do módulo do Jackson para Kotlin, do DbUnit e das bibliotecas de teste modernas configuradas no build.gradle.kts:

 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
// build.gradle.kts (Kotlin DSL)
plugins {
    alias(libs.plugins.kotlin.jvm) // Kotlin 2.4.21
    alias(libs.plugins.kotlin.spring)
    alias(libs.plugins.kotlin.jpa)
    alias(libs.plugins.spring.boot) // Spring Boot 4.1.2
    alias(libs.plugins.spring.dependency.management)
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.postgresql:postgresql:42.7.5")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.dbunit:dbunit:2.7.3")
    testImplementation("com.github.springtestdbunit:spring-test-dbunit:1.3.0")
    testImplementation("org.testcontainers:postgresql:2.0.5")
    testImplementation("org.testcontainers:junit-jupiter:2.0.5")
    testImplementation("org.assertj:assertj-core:3.27.7")
}

testing {
    suites {
        val test by getting(JvmTestSuite::class) {
            useJUnitJupiter("6.0.3")
        }
    }
}

2. Criando Nosso Conjunto de Dados do DbUnit (DbUnit Data Set)

Precisamos escrever testes de integração para um repositório que realiza a leitura e manipulação de informações a partir da tabela products. Quando inicializamos o banco de dados em um estado prévio conhecido, precisamos definir o valor da coluna reviews, a qual armazena um documento JSON contendo a lista de avaliações do produto.

Quando configuramos o valor dessa coluna em nosso dataset XML do DbUnit, a abordagem mais direta e recomendada é utilizar a representação textual (String) do documento JSON. Como o XML utiliza aspas duplas para delimitar o valor dos atributos, devemos escapar as aspas internas do JSON através da entidade ".

Após criarmos nosso dataset do DbUnit (products.xml), o arquivo fica estruturado da seguinte forma:

1
2
3
4
5
6
7
<dataset>
    <products id="1"
              product_name="Clean Code"
              product_description="Guia prático sobre boas práticas e artesanato de software"
              reviews="[{&quot;id&quot;: 1, &quot;author&quot;: &quot;Petri Kainulainen&quot;, &quot;score&quot;: 5, &quot;text&quot;: &quot;Review excelente sobre engenharia de software&quot;}]"
    />
</dataset>

Por que o DbUnit precisa de um tipo customizado para JSON?
No PostgreSQL, colunas do tipo json ou jsonb não são mapeadas por padrão para tipos JDBC convencionais como VARCHAR. O driver JDBC as identifica com o código genérico Types.OTHER (1111) ou sqlTypeName = "json" / "jsonb". Sem um tipo específico registrado, o DbUnit tenta convertê-las para tipos incompatíveis ou lança uma TypeCastException durante o carregamento dos registros.

A seguir, veremos como criar o tipo de dado customizado no DbUnit que gerencia essa conversão.


3. Escrevendo Nosso Tipo de Dado Customizado no DbUnit

Para criar um tipo de dado customizado no DbUnit, precisamos criar uma nova classe que estenda AbstractDataType e implementar o método typeCast() da classe DataType.

Podemos implementar o método typeCast() seguindo estes passos:

  1. Se o valor fornecido como parâmetro do método for um objeto String, retornamos o próprio valor fornecido.
  2. Se o valor fornecido como parâmetro do método não for um objeto String, lançamos uma nova exceção (IllegalArgumentException ou TypeCastException).

No construtor da nossa classe, invocamos o construtor da superclasse AbstractDataType fornecendo quatro parâmetros fundamentais:

  • O nome do tipo SQL suportado pelo nosso tipo DbUnit ("json").
  • O código do tipo SQL JDBC correspondente (Types.OTHER).
  • A classe Java que representa o valor em memória (String::class.java).
  • Um booleano indicando se o tipo suporta valores numéricos (false).

Após sobrescrevermos o método typeCast() e o construtor, o código-fonte da nossa classe JsonDataType em Kotlin 2.4.21 apresenta a seguinte estrutura:

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

import org.dbunit.dataset.datatype.AbstractDataType
import org.dbunit.dataset.datatype.TypeCastException
import java.sql.Types

class JsonDataType : AbstractDataType(POSTGRESQL_TYPE_JSON, Types.OTHER, String::class.java, false) {

    companion object {
        private const val POSTGRESQL_TYPE_JSON = "json"
    }

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

        if (value is String) {
            return value
        }

        throw IllegalArgumentException(
            String.format(
                "Tipo não suportado. Esperava String, mas recebeu: %s",
                value.javaClass.name
            )
        )
    }
}

Suporte a JSONB no PostgreSQL:
O PostgreSQL oferece dois formatos de armazenamento de JSON: json (que preserva o texto exato, incluindo espaços e ordem de chaves) e jsonb (formato binário decomposto, indexável via índices GIN e muito mais performático para buscas). Ambos recebem a mesma representação textual via JDBC ao inserir valores. A classe JsonDataType acima funciona com perfeição para ambos os casos.

Agora que criamos o nosso tipo customizado, precisamos descobrir como habilitá-lo no DbUnit.


4. Habilitando Nosso Tipo de Dado Customizado no DbUnit

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

Podemos implementar esse método seguindo estes passos:

  1. Se o tipo de dado da coluna processada for json (ou jsonb), retornamos uma nova instância da nossa classe JsonDataType.
  2. Caso contrário, invocamos o método createDataType() da superclasse (PostgresqlDataTypeFactory) e retornamos o tipo de dado retornado por ela. Isso assegura que o DbUnit continue manipulando todos os tipos padrão do banco de dados (como inteiros, datas e varchars).

Após implementarmos o método createDataType(), o código-fonte da classe CustomPostgreSQLDataTypeFactory em Kotlin 2.4.21 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
package com.testwithspring.master.dbunitdatatypes

import org.dbunit.dataset.datatype.DataType
import org.dbunit.dataset.datatype.DataTypeException
import org.dbunit.ext.postgresql.PostgresqlDataTypeFactory

class CustomPostgreSQLDataTypeFactory : PostgresqlDataTypeFactory() {

    companion object {
        private const val SQL_TYPE_NAME_JSON = "json"
        private const val SQL_TYPE_NAME_JSONB = "jsonb"
    }

    @Throws(DataTypeException::class)
    override fun createDataType(
        sqlType: Int,
        sqlTypeName: String?,
        tableName: String?,
        columnName: String?
    ): DataType {
        if (SQL_TYPE_NAME_JSON.equals(sqlTypeName, ignoreCase = true) ||
            SQL_TYPE_NAME_JSONB.equals(sqlTypeName, ignoreCase = true)
        ) {
            return JsonDataType()
        }

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

5. Configurando a Conexão do DbUnit no Spring Boot

Para que o framework spring-test-dbunit utilize a nossa fábrica customizada CustomPostgreSQLDataTypeFactory, precisamos expor os beans de configuração do DbUnit no contexto de teste do Spring Boot.

Configuramos dois beans principais em uma classe de configuração de testes:

  1. Um bean DatabaseConfigBean configurado com a nossa fábrica CustomPostgreSQLDataTypeFactory.
  2. Um bean DatabaseDataSourceConnectionFactoryBean que associa o DataSource ativo do PostgreSQL (provido pelo Testcontainers) à nossa configuração customizada 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,
        dbUnitDatabaseConfig: DatabaseConfigBean
    ): DatabaseDataSourceConnectionFactoryBean {
        val factoryBean = DatabaseDataSourceConnectionFactoryBean()
        factoryBean.setDataSource(dataSource)
        factoryBean.setDatabaseConfig(dbUnitDatabaseConfig)
        return factoryBean
    }
}

Também criamos a meta-anotação @DatabaseIntegrationTest, que encapsula a infraestrutura necessária para carregar o contexto da aplicação, a configuração do banco e o listener de execução do DbUnit:

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

import com.github.springtestdbunit.DbUnitTestExecutionListener
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.TestExecutionListeners
import java.lang.annotation.Inherited

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Inherited
@SpringBootTest(classes = [CustomDbUnitDataTypeApplication::class, TestDatabaseConfiguration::class])
@TestExecutionListeners(
    listeners = [DbUnitTestExecutionListener::class],
    mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
annotation class DatabaseIntegrationTest

6. Mapeamento de Domínio com Kotlin e Jackson

No lado da aplicação, criamos nosso modelo de domínio em Kotlin utilizando data classes imutáveis. O Jackson Kotlin Module permite serializar e deserializar o documento JSONB de forma transparente:

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

import java.io.Serializable

data class Review(
    val id: Long,
    val author: String,
    val score: Int,
    val text: String
) : Serializable

A entidade JPA Product armazena a lista de avaliações mapeada diretamente para a coluna reviews do PostgreSQL via anotação de conversão ou tipo JSON do Hibernate 7 / Spring Boot 4:

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

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes

@Entity
@Table(name = "products")
class Product(
    @Id
    val id: Long,

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

    @Column(name = "product_description", nullable = false)
    val description: String,

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(name = "reviews", columnDefinition = "jsonb")
    val reviews: List<Review> = emptyList()
)

E a interface de repositório do 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>

7. Escrevendo o Teste de Integração com JUnit 6 e AssertJ

Com a infraestrutura de tipos do DbUnit e o modelo de domínio configurados, escrevemos o teste de integração para verificar se o Spring Data JPA consegue carregar o registro populado pelo DbUnit e se o Jackson deserializa corretamente o documento JSONB:

 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.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
@DisplayName("Testes de Integração com Colunas JSONB no PostgreSQL")
class ProductRepositoryTest(
    @Autowired private val productRepository: ProductRepository
) {

    @Test
    @DatabaseSetup("products.xml")
    @DisplayName("Deve carregar produto e deserializar coluna JSONB em lista de reviews")
    fun deveCarregarProdutoComColunaJsonb() {
        val produtoEncontrado = productRepository.findById(1L)

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

        assertThat(produto.id).isEqualTo(1L)
        assertThat(produto.name).isEqualTo("Clean Code")
        assertThat(produto.description).isEqualTo("Guia prático sobre boas práticas e artesanato de software")

        assertThat(produto.reviews)
            .hasSize(1)
            .first()
            .satisfies { review ->
                assertThat(review.id).isEqualTo(1L)
                assertThat(review.author).isEqualTo("Petri Kainulainen")
                assertThat(review.score).isEqualTo(5)
                assertThat(review.text).isEqualTo("Review excelente sobre engenharia de software")
            }
    }
}

Durante a execução deste teste:

  1. O Testcontainers assegura que o container PostgreSQL esteja em execução.
  2. O listener do DbUnit lê o dataset products.xml.
  3. Ao encontrar a coluna reviews, o DbUnit consulta CustomPostgreSQLDataTypeFactory, que retorna nosso JsonDataType.
  4. O JsonDataType valida que o conteúdo é uma String e o entrega ao driver JDBC do PostgreSQL como tipo compatível.
  5. O Hibernate/Spring Data lê o registro do PostgreSQL e o Jackson Kotlin Module reconstrói a lista tipada List<Review> sem necessidade de conversores manuais adicionais.

Resumo e Pontos-Chave

Nesta aula aprendemos como tratar colunas do tipo JSON/JSONB do PostgreSQL em testes de integração:

  • Representação Textual no Dataset: Ao definir o valor de uma coluna que armazena um documento JSON em um dataset XML do DbUnit, devemos utilizar a representação textual do JSON, escapando as aspas com &quot;.
  • Criação de JsonDataType: Estendemos AbstractDataType configurando o nome json (ou jsonb), o código Types.OTHER e garantindo no método typeCast() que valores String sejam aceitos diretamente.
  • Registro na Fábrica de Tipos: Estendemos PostgresqlDataTypeFactory e sobrescrevemos o método createDataType(). Se o tipo SQL for json ou jsonb, retornamos nossa instância de JsonDataType; para os demais tipos, delegamos à superclasse.
  • Configuração no Spring Boot: Registramos a fábrica customizada via DatabaseConfigBean e associamos ao DataSource gerenciado nos testes pelo Testcontainers.
  • Mapeamento Tipado em Kotlin: Em conjunto com Jackson Kotlin Module e @JdbcTypeCode(SqlTypes.JSON), o documento JSONB é convertido de/para classes de dados Kotlin imutáveis de forma elegante e segura.