Inicializando Cenários de Dados nos Testes de Integração

Ao concluir esta lição, você será capaz de:

  • Saber como inicializar o nosso banco de dados em um estado conhecido antes da execução dos testes de integração (tanto via @DatabaseSetup do DbUnit quanto via @Sql nativo do Spring Test).
  • Compreender como escrever asserções declarativas e programáticas para os dados encontrados no banco de dados após a execução do método testado.

Esta lição assume que:


1. Especificando a Localização do Dataset Utilizado

Podemos especificar a localização de um dataset do DbUnit (ou script @Sql) seguindo estas duas regras fundamentais de resolução de classpath na JVM:

  • Mesmo pacote da classe de teste: Se o arquivo de dataset estiver localizado no mesmo pacote (diretório correspondente em src/integrationTest/resources) que a classe de teste de integração, podemos especificar sua localização informando apenas o nome do arquivo.
  • Pacote diferente da classe de teste: Se o dataset não estiver no mesmo pacote da classe de teste de integração, devemos especificar sua localização usando o caminho completo (absoluto no classpath) do arquivo.

Por exemplo, se o nome do nosso arquivo de dataset for data-set.xml (ou data-set.sql), especificamos sua localização seguindo estas regras:

  • Se o arquivo estiver no mesmo pacote que nossa classe de teste de integração, usamos a string relativa: "data-set.xml".
  • Se o arquivo não estiver no mesmo pacote da classe de teste e pertencer ao pacote base com.testwithspring.integrationtest, usamos a string com barra inicial: "/com/testwithspring/integrationtest/data-set.xml".

A seguir, veremos como configurar o dataset responsável por inserir os dados de teste no banco de dados.


2. Inserindo Dados de Teste no Nosso Banco de Dados

Quando configuramos o dataset do DbUnit (ou script @Sql) que inicializa nosso banco de dados em um estado conhecido antes de cada teste, devemos escolher uma destas três opções:

Primeiro, se todos os métodos de teste da nossa classe utilizarem exatamente o mesmo conjunto de dados inicial, podemos especificar o dataset anotando a própria classe de teste com @DatabaseSetup (ou @Sql).

Por exemplo, se quisermos usar um dataset que não está no mesmo pacote da nossa classe de teste e reside em com.testwithspring.integrationtest, o código-fonte da nossa classe de teste em Kotlin ficará assim:

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

import com.github.springtestdbunit.annotation.DatabaseSetup
import org.junit.jupiter.api.Test

@DatabaseSetup("/com/testwithspring/integrationtest/messages.xml")
// Equivalente nativo com Spring @Sql:
// @Sql("classpath:/com/testwithspring/integrationtest/messages.sql")
class IntegrationTest {

    @Test
    fun `deve executar operacao X`() {
        // ...
    }

    @Test
    fun `deve executar operacao Y`() {
        // ...
    }
}

Segundo, se cada método de teste da nossa classe precisar de um cenário de dados diferente, devemos especificar os datasets anotando individualmente os métodos de teste com @DatabaseSetup (ou @Sql).

Por exemplo, se quisermos usar datasets localizados no mesmo pacote da nossa classe de teste, o código-fonte ficará assim:

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

import com.github.springtestdbunit.annotation.DatabaseSetup
import org.junit.jupiter.api.Test

class IntegrationTest {

    @Test
    @DatabaseSetup("messages-x.xml")
    fun `deve executar operacao X`() {
        // ...
    }

    @Test
    @DatabaseSetup("messages-y.xml")
    fun `deve executar operacao Y`() {
        // ...
    }
}

Terceiro, se quisermos compor o estado inicial do banco combinando múltiplos datasets modulares (por exemplo, um arquivo para usuários e outro para tarefas ou mensagens), podemos passar múltiplos caminhos na anotação @DatabaseSetup (ou @Sql).

Por exemplo, se todos os métodos da nossa classe de teste usarem a mesma combinação de datasets localizados no mesmo pacote da classe, o código ficará assim:

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

import com.github.springtestdbunit.annotation.DatabaseSetup
import org.junit.jupiter.api.Test

@DatabaseSetup(
    "messages-1.xml",
    "messages-2.xml"
)
class IntegrationTest {

    @Test
    fun `deve executar operacao X`() {
        // ...
    }

    @Test
    fun `deve executar operacao Y`() {
        // ...
    }
}

Quando utilizamos múltiplos datasets, o DbUnit (assim como o @Sql) processa os arquivos rigorosamente na ordem em que foram declarados. Em outras palavras, nossos datasets serão processados nesta sequência:

  1. messages-1.xml
  2. messages-2.xml

Nota: Podemos utilizar essa mesma técnica de múltiplos arquivos mesmo quando anotamos métodos de teste individuais com @DatabaseSetup.

Além disso, se não especificarmos explicitamente a operação (DatabaseOperation) usada para inicializar o banco de dados, o Spring Test DbUnit utiliza seu comportamento padrão (DatabaseOperation.CLEAN_INSERT), que executa as duas operações a seguir antes do teste:

  1. Remove todas as linhas das tabelas mencionadas nos datasets informados (em ordem reversa para respeitar chaves estrangeiras).
  2. Insere os dados de teste definidos nos datasets no banco de dados.

Embora o comportamento padrão seja ideal para a imensa maioria dos casos de uso, podemos customizar a DatabaseOperation utilizada especificando o atributo type da anotação @DatabaseSetup.

Por exemplo, se quisermos apenas atualizar registros existentes usando DatabaseOperation.UPDATE, o código-fonte da nossa classe de teste ficará assim:

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

import com.github.springtestdbunit.annotation.DatabaseOperation
import com.github.springtestdbunit.annotation.DatabaseSetup
import org.junit.jupiter.api.Test

@DatabaseSetup(
    value = ["messages.xml"],
    type = DatabaseOperation.UPDATE
)
class IntegrationTest {

    @Test
    fun `deve executar operacao X`() {
        // ...
    }

    @Test
    fun `deve executar operacao Y`() {
        // ...
    }
}

Leitura Adicional:

Vamos avançar e descobrir como escrever asserções para os dados encontrados no banco de dados após a execução de operações de escrita.


3. Escrevendo Asserções para os Dados Encontrados no Banco de Dados

Quando queremos verificar declarativamente o estado do banco de dados ao final de um método de teste com DbUnit, anotamos o método de teste com @ExpectedDatabase. Ao usar essa anotação, normalmente especificamos os valores destes dois atributos:

  • O atributo value especifica a localização do dataset contendo o estado esperado.
  • O atributo assertionMode especifica o modo de asserção utilizado na comparação.

Vejamos dois exemplos que demonstram como utilizar esses atributos na prática.

Primeiro, especificamos a localização do dataset esperado seguindo exatamente as mesmas regras de caminho relativo ou absoluto descritas no início desta lição.

Por exemplo, se quisermos verificar dois resultados esperados distintos em dois métodos de teste, nossa classe de teste ficará assim:

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

import com.github.springtestdbunit.annotation.ExpectedDatabase
import org.junit.jupiter.api.Test

class IntegrationTest {

    @Test
    @ExpectedDatabase("integration-test-should-do-x-expected.xml")
    fun `deve executar operacao X`() {
        // ...
    }

    @Test
    @ExpectedDatabase("integration-test-should-do-y-expected.xml")
    fun `deve executar operacao Y`() {
        // ...
    }
}

Regras de Ouro para Datasets de Asserção:

Ao criar e especificar datasets de verificação (@ExpectedDatabase), devemos seguir estas três regras:

  1. Devemos usar datasets pequenos que validem apenas um conceito lógico por teste.
  2. Devemos colocar os datasets de asserção no mesmo pacote da nossa classe de teste de integração.
  3. Devemos nomear nossos arquivos de dataset esperado seguindo a convenção: [nome-do-caso-de-teste]-[resultado-esperado]-expected.xml.

Segundo, nunca devemos usar o modo de asserção padrão (DatabaseAssertionMode.DEFAULT) porque ele tenta verificar o esquema e o conteúdo do banco de dados inteiro (incluindo tabelas de migração como databasechangelog do Liquibase/Flyway e colunas que não interessam ao teste). Se usarmos o modo padrão, nossos datasets ficarão gigantescos, frágeis e ilegíveis. Isso significa que devemos sempre escolher um destes dois modos de asserção:

  • DatabaseAssertionMode.NON_STRICT: Permite criar datasets enxutos que declaram apenas tabelas e colunas específicas relevantes para o teste. O DbUnit ignora quaisquer tabelas e colunas não especificadas no XML esperado.
  • DatabaseAssertionMode.NON_STRICT_UNORDERED: É uma extensão do modo NON_STRICT que também ignora a ordem física em que as linhas são retornadas pelo banco ao comparar o dataset esperado com o estado real da tabela.

Por exemplo, se quisermos escrever uma asserção que utiliza o DatabaseAssertionMode.NON_STRICT, o código-fonte da nossa classe de teste de integração ficará assim:

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

import com.github.springtestdbunit.annotation.ExpectedDatabase
import com.github.springtestdbunit.assertion.DatabaseAssertionMode
import org.junit.jupiter.api.Test

class IntegrationTest {

    @Test
    @ExpectedDatabase(
        value = "integration-test-should-do-x-expected.xml",
        assertionMode = DatabaseAssertionMode.NON_STRICT
    )
    fun `deve executar operacao X`() {
        // ...
    }
}

Alternativa Moderna com JdbcClient e AssertJ:

Em projetos Spring Boot 4.1 que optam por usar @Sql em vez de XMLs do DbUnit, aplicamos exatamente o mesmo princípio de NON_STRICT consultando apenas as colunas do conceito lógico testado via JdbcClient e AssertJ:

1
2
3
4
5
val row = jdbcClient.sql("SELECT title, status FROM tasks WHERE id = :id")
    .param("id", 1L)
    .query { rs, _ -> rs.getString("title") to rs.getString("status") }
    .single()
assertThat(row).isEqualTo("Write example application" to "CLOSED")

Leitura Adicional:

Vamos resumir o que aprendemos nesta lição.


Resumo

Esta lição nos ensinou quatro pontos essenciais:

  • Se o dataset do DbUnit (ou script @Sql) estiver no mesmo pacote da classe de teste de integração, podemos especificar sua localização informando apenas o nome do arquivo.
  • Se o dataset não estiver no mesmo pacote da classe de teste de integração, devemos especificar sua localização usando o caminho absoluto completo no classpath.
  • Ao inicializar nosso banco de dados em um estado conhecido, na maioria das vezes não precisamos sobrescrever a DatabaseOperation padrão (CLEAN_INSERT).
  • Ao escrever asserções para os dados encontrados no banco de dados com @ExpectedDatabase, nunca devemos usar o modo de asserção padrão — devemos sempre optar por DatabaseAssertionMode.NON_STRICT ou NON_STRICT_UNORDERED.