Introdução à Preparação de Datasets de Banco de Dados

Ao concluirmos esta aula, nós:

  • Saberemos o que é o DbUnit e como ele se compara com a abordagem nativa moderna (@Sql e AssertJ / JdbcClient) do Spring Boot 4.
  • Saberemos como obter as dependências necessárias no Maven e no Gradle 9.
  • Compreenderemos o esquema relacional do banco de dados da nossa aplicação de exemplo (Task Tracker).
  • Saberemos como criar datasets que inicializam o banco de dados em um estado conhecido antes da execução de cada teste e verificam os dados persistidos após o teste.

O Que É o DbUnit (e a Evolução para @Sql + Testcontainers)?

Quando escrevemos testes de integração que interagem com um banco de dados relacional (como PostgreSQL), enfrentamos dois desafios essenciais:

  1. Inicializar o banco de dados em um estado conhecido antes que o método de teste seja executado.
  2. Escrever asserções sobre os dados encontrados no banco de dados após o método de teste ter terminado sua execução (quando testamos operações de escrita como INSERT, UPDATE ou DELETE).

No ecossistema Java/Spring, existem duas abordagens consagradas para resolver esses desafios:

  • Abordagem Clássica com DbUnit (org.dbunit:dbunit + spring-test-dbunit): Uma extensão do JUnit que lê datasets em formato Flat XML, limpa as tabelas em ordem reversa, insere os registros antes do teste (@DatabaseSetup) e compara o estado final das tabelas com um XML esperado (@ExpectedDatabase).
  • Abordagem Nativa Moderna do Spring Boot 4 (@Sql + JdbcClient / AssertJ): Executa scripts SQL determinísticos antes do teste via @Sql e verifica o estado final consultando o banco ou os repositórios com asserções fluentes do AssertJ.

Neste tópico, estudaremos em detalhes tanto o funcionamento dos datasets declarativos do DbUnit (incluindo a ordem de limpeza, column sensing e replacement datasets) quanto sua equivalência direta em SQL nativo (@Sql), pois os princípios arquiteturais de organização de dados de teste aplicam-se igualmente a ambos!


Obtendo as Dependências Necessárias

Para utilizarmos o DbUnit em conjunto com o suporte a banco de dados do Spring Test, declaramos a dependência no nosso script de build.

Se estivermos usando o Maven (pom.xml):

1
2
3
4
5
6
<dependency>
    <groupId>org.dbunit</groupId>
    <artifactId>dbunit</artifactId>
    <version>2.8.0</version>
    <scope>test</scope>
</dependency>

Se estivermos usando o Gradle 9 (Kotlin DSL — build.gradle.kts):

1
2
3
4
5
dependencies {
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.boot:spring-boot-starter-jdbc")
    testImplementation("org.dbunit:dbunit:2.8.0")
}

Vamos conhecer agora o formato de datasets do DbUnit e o esquema de banco de dados da nossa aplicação!


Introdução aos Datasets Flat XML do DbUnit

Utilizamos o formato Flat XML do DbUnit porque ele é muito mais conciso e fácil de ler do que o formato XML verboso original. As regras para criar um dataset Flat XML são:

  • O arquivo XML deve ter um elemento raiz chamado <dataset>.
  • O elemento raiz pode conter múltiplos elementos filhos.
  • Cada elemento filho corresponde a uma única linha (registro) de uma tabela do banco de dados. O nome da tag XML especifica o nome da tabela (por exemplo, <tasks ... />).
  • Os atributos de cada elemento descrevem os valores das colunas daquela linha (por exemplo, id="1" title="foo").
  • Se quisermos especificar que uma tabela do banco de dados deve estar vazia (zero linhas), adicionamos um elemento vazio sem atributos (por exemplo, <tags/>).

Introdução ao Banco de Dados da Nossa Aplicação de Exemplo

O diagrama a seguir descreve a estrutura relacional do banco de dados da nossa aplicação de gerenciamento de tarefas (Task Tracker):

erDiagram persons ||--o{ tasks : "creator_id / assignee_id / closer_id" tasks ||--o{ tasks_tags : "task_id" tags ||--o{ tasks_tags : "tag_id" persons { bigint id PK varchar email_address varchar name varchar password varchar user_role } tasks { bigint id PK bigint assignee_id FK bigint closer_id FK bigint creator_id FK varchar description varchar title varchar status varchar resolution } tags { bigint id PK varchar name } tasks_tags { bigint task_id FK bigint tag_id FK }

Como podemos observar, o nosso banco de dados possui quatro tabelas:

Primeiro, a tabela persons armazena os dados das pessoas que utilizam o nosso gerenciador de tarefas. Ela possui as seguintes colunas:

  • id: Identificador único da pessoa (chave primária).
  • email_address: Endereço de e-mail da pessoa.
  • name: Nome completo da pessoa.
  • password: Senha (hash) da pessoa.
  • user_role: Papel de autorização da pessoa (ex: ROLE_USER, ROLE_ADMIN).

Segundo, a tabela tasks armazena as informações das nossas tarefas. Ela possui as seguintes colunas:

  • id: Identificador único de cada tarefa (chave primária).
  • assignee_id: Identifica a pessoa responsável pela tarefa (persons.id, opcional).
  • closer_id: Identifica a pessoa que encerrou a tarefa (persons.id, opcional).
  • creator_id: Identifica a pessoa que criou a tarefa (persons.id, obrigatório).
  • description: Descrição detalhada da tarefa.
  • title: Título da tarefa.
  • status: Status atual da tarefa (OPEN, IN_PROGRESS, CLOSED).
  • resolution: Resolução da tarefa quando encerrada (DONE, DUPLICATE, WONT_DO, opcional).

Terceiro, a tabela tags armazena as etiquetas que podem ser associadas às tarefas:

  • id: Identificador único da tag (chave primária).
  • name: Nome da tag.

Quarto, a tabela tasks_tags é uma tabela de junção (join table N:N) que vincula tarefas a tags:

  • task_id: Identifica a tarefa (tasks.id).
  • tag_id: Identifica a tag (tags.id).

Vamos descobrir agora como criar datasets que inserem informações nesse banco de dados!


Inserindo Dados no Nosso Banco de Dados

Vamos analisar três exemplos práticos que demonstram como o DbUnit (e scripts SQL equivalentes) limpa e popula as tabelas do banco de dados.

Exemplo 1: Uma Tarefa Aberta que Não Possui Tags

Se quisermos garantir que o nosso banco de dados contenha exatamente uma tarefa aberta (OPEN) e nenhuma tag, usamos o seguinte dataset:

1
2
3
4
5
6
7
8
9
<dataset>
    <tasks id="1"
           creator_id="4"
           description="bar"
           title="foo"
           status="OPEN"/>
    <tags/>
    <tasks_tags/>
</dataset>

Quando utilizamos este dataset antes de um método de teste, o DbUnit executa duas operações:

Primeiro, ele exclui (DELETE) todas as linhas encontradas nas tabelas mencionadas no dataset, percorrendo-as em ordem reversa. Ou seja, o DbUnit limpa as tabelas exatamente nesta ordem: tasks_tags, tags e por último tasks (evitando violações de chave estrangeira!).

Segundo, ele insere uma linha na tabela tasks. A instrução INSERT executada no banco de dados é equivalente a:

1
2
3
4
5
6
7
8
DELETE FROM tasks_tags;
DELETE FROM tags;
DELETE FROM tasks;

INSERT INTO tasks
    (id, creator_id, description, title, status)
VALUES
    (1, 4, 'bar', 'foo', 'OPEN');

Observe que todas as colunas omitidas no elemento XML <tasks> (como assignee_id, closer_id e resolution) receberão o valor NULL no banco de dados.

Exemplo 2: Uma Tarefa Aberta que Possui Uma Tag

Se quisermos garantir que o nosso banco de dados possua uma tarefa aberta associada a uma tag, utilizamos o seguinte dataset:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<dataset>
    <tasks id="1"
           creator_id="4"
           description="bar"
           title="foo"
           status="OPEN"/>
    <tags id="1"
          name="Easy"/>
    <tasks_tags task_id="1"
                tag_id="1"/>
</dataset>

Quando utilizamos este dataset, o DbUnit realiza as seguintes operações:

Primeiro, o DbUnit limpa as tabelas em ordem reversa: tasks_tags, tags e tasks.

Segundo, o DbUnit insere os registros exatamente na ordem em que foram declarados no arquivo XML, executando os seguintes comandos INSERT:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
INSERT INTO tasks
    (id, creator_id, description, title, status)
VALUES
    (1, 4, 'bar', 'foo', 'OPEN');

INSERT INTO tags
    (id, name)
VALUES
    (1, 'Easy');

INSERT INTO tasks_tags
    (task_id, tag_id)
VALUES
    (1, 1);

Portanto, para evitarmos erros de violação de integridade referencial (foreign key constraint violation), devemos sempre declarar as linhas das tabelas pai (persons, tasks, tags) antes das tabelas filhas (tasks_tags)!

Exemplo 3: Duas Tarefas com Atributos Diferentes (Column Sensing)

Suponha que queiramos inserir duas tarefas no banco de dados, sendo que apenas a segunda tarefa foi atribuída a um responsável (assignee_id="1"):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<dataset>
    <tasks id="1"
           creator_id="4"
           description="bar"
           title="foo"
           status="OPEN"/>
    <tasks id="2"
           assignee_id="1"
           creator_id="4"
           description="John Doe will do this."
           title="Assigned task"
           status="OPEN"/>
    <tags/>
    <tasks_tags/>
</dataset>

Como os dois elementos <tasks> possuem conjuntos diferentes de atributos XML (o primeiro não tem assignee_id, enquanto o segundo tem), o que é inserido no banco de dados depende do recurso chamado Column Sensing do DbUnit:

Primeiro, se não habilitarmos o column sensing, o DbUnit olha apenas para os atributos do primeiro elemento <tasks> do arquivo para decidir quais colunas farão parte do INSERT! Isso significa que o atributo assignee_id="1" da segunda tarefa seria silenciosamente ignorado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- Sem Column Sensing (COMPORTAMENTO PERIGOSO!): assignee_id é ignorado na segunda linha!
INSERT INTO tasks
    (id, creator_id, description, title, status)
VALUES
    (1, 4, 'bar', 'foo', 'OPEN');

INSERT INTO tasks
    (id, creator_id, description, title, status)
VALUES
    (2, 4, 'John Doe will do this.', 'Assigned task', 'OPEN');

Segundo, se habilitarmos o column sensing (o que faremos quando configurarmos nossa integração com o Spring Test!), o DbUnit lê todo o arquivo XML antes de montar os comandos INSERT. Com o column sensing ativo, ele executa os INSERTs corretos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- Com Column Sensing ativo: todas as colunas presentes no arquivo são respeitadas!
INSERT INTO tasks
    (id, assignee_id, creator_id, description, title, status)
VALUES
    (1, NULL, 4, 'bar', 'foo', 'OPEN');

INSERT INTO tasks
    (id, assignee_id, creator_id, description, title, status)
VALUES
    (2, 1, 4, 'John Doe will do this.', 'Assigned task', 'OPEN');

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

Vamos analisar dois exemplos que demonstram como o DbUnit verifica o estado do banco de dados após a execução de um teste.

Exemplo 1: Verificando Uma Tarefa Sem Tags

Se quisermos verificar que o nosso banco de dados contém uma tarefa aberta com título "foo", descrição "bar" e nenhuma tag, podemos usar o seguinte dataset esperado:

1
2
3
4
5
6
7
8
9
<dataset>
    <tasks id="1"
           creator_id="1"
           description="bar"
           title="foo"
           status="OPEN"/>
    <tags/>
    <tasks_tags/>
</dataset>

Ao comparar este dataset com o estado real do banco de dados, o DbUnit garante que:

  1. A tabela tasks_tags possui zero linhas.
  2. A tabela tags possui zero linhas.
  3. A tabela tasks possui uma linha contendo os valores esperados para id, creator_id, description, title e status. Importante: se uma coluna da tabela não for especificada no XML esperado, o DbUnit simplesmente ignora aquela coluna durante a comparação! Ou seja, o XML acima não verifica se assignee_id, closer_id ou resolution são NULL!

E se quisermos verificar explicitamente que o valor de uma coluna no banco de dados é NULL? Para isso, utilizamos um Replacement Data Set (Dataset de Substituição), definindo um marcador como [null] (ex: assignee_id="[null]"), que veremos em detalhes nas próximas aulas!

Exemplo 2: Verificando Duas Tarefas

Se quisermos verificar que o banco de dados possui duas tarefas, usamos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<dataset>
    <tasks id="1"
           creator_id="4"
           description="bar"
           title="foo"
           status="OPEN"/>
    <tasks id="2"
           creator_id="4"
           description="John Doe will do this."
           title="Assigned task"
           status="OPEN"/>
    <tags/>
    <tasks_tags/>
</dataset>

Note que, ao escrevermos asserções com datasets esperados do DbUnit, todas as linhas de uma mesma tabela precisam declarar os mesmos atributos (ou usar [null] nas linhas onde a coluna for nula).


Resumo

Esta aula nos ensinou cinco conceitos essenciais:

  • O DbUnit é uma biblioteca que auxilia na escrita de testes de integração automatizados que manipulam bancos de dados relacionais (em conjunto ou como alternativa ao @Sql do Spring Test).
  • Ao inserir dados no banco, o DbUnit limpa as tabelas presentes no dataset em ordem reversa para respeitar as chaves estrangeiras.
  • Ao inserir dados no banco, o DbUnit executa as instruções INSERT na ordem exata em que as linhas aparecem no arquivo.
  • Ao escrever asserções sobre o banco de dados, o DbUnit ignora as colunas que foram omitidas no dataset esperado.
  • Para que múltiplas linhas da mesma tabela possam especificar valores nulos e não-nulos de forma consistente, devemos habilitar o column sensing e utilizar replacement datasets ([null]).