Testes de Integração para Repositórios Spring Data JPA

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

  • Compreender que tipo de dataset do DbUnit (ou script SQL) devemos usar ao inicializar nosso banco de dados em um estado conhecido para testes de repositório.
  • Escrever testes de integração limpos e confiáveis para repositórios gerenciados pelo Spring Data JPA.

Esta lição assume que:


1. Introdução ao Sistema Sob Teste (SUT)

Durante esta lição, escreveremos testes de integração para um método de repositório customizado que busca tarefas no banco de dados. Esse método se chama search() e recebe o termo de busca (searchTerm) como parâmetro. A implementação do método search() cumpre os seguintes requisitos de negócio:

  • Deve retornar as informações das tarefas cujo title (título) ou description (descrição) contenha o termo de busca informado.
  • Deve ordenar as tarefas retornadas em ordem crescente pelo title da tarefa.
  • A busca deve ser case-insensitive (ignorar maiúsculas e minúsculas).

O código-fonte da interface que declara nossos métodos customizados de repositório em Kotlin é o seguinte:

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

interface CustomTaskRepository {

    fun findAll(): List<TaskListDTO>

    fun search(searchTerm: String): List<TaskListDTO>
}

Como funcionam repositórios customizados no Spring Data JPA:

Na prática, escreveremos testes de integração injetando a interface principal TaskRepository (que estende JpaRepository<Task, Long> e CustomTaskRepository). Como implementamos nossa camada de persistência com Spring Data JPA e queríamos fornecer uma consulta otimizada (via JPQL, Criteria API, Querydsl ou JdbcClient) para esses dois métodos de leitura retornando projeções DTO diretamente, nós os declaramos na interface fragmento CustomTaskRepository (cuja implementação TaskRepositoryImpl é automaticamente detectada pelo Spring Data).

A classe TaskListDTO contém o resumo de uma única tarefa retornada pelo método search(). Seu código-fonte em Kotlin é o seguinte:

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

data class TaskListDTO(
    val id: Long,
    val title: String,
    val status: TaskStatus
)

A seguir, precisamos inicializar nosso banco de dados em um estado conhecido antes da execução dos testes de integração. Vamos ver como planejar esse dataset.


2. Inicializando o Banco de Dados em um Estado Conhecido

Como aprendemos nas lições de boas práticas de preparação de dados, devemos utilizar datasets que sejam o menor possível. Além disso, como estamos escrevendo testes de integração para um método de consulta (search()) que lê informações do banco de dados, precisamos garantir que esse único dataset nos permita testar todos os casos de uso necessários.

Isso significa que devemos inicializar nosso banco de dados criando um dataset que insira duas linhas na tabela tasks. Inserimos exatamente duas linhas na tabela tasks por duas razões:

  • Se quisermos garantir que as tarefas sejam retornadas na ordem correta quando mais de um registro corresponder à busca, precisamos de pelo menos duas linhas na tabela tasks.
  • Não precisamos inserir nenhuma linha nas outras tabelas do banco (como tags ou tasks_tags) porque a implementação do método search() consulta exclusivamente os dados da tabela tasks (e as chaves estrangeiras opcionais como assignee_id e closer_id não exigem joins na listagem resumida).

O nome do nosso arquivo de dataset é tasks.xml, e seu conteúdo é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<dataset>
    <tasks id="1"
           assignee_id="1"
           closer_id="1"
           creation_time="2016-12-3 11:41:28"
           creator_id="1"
           description="This example contains integration tests"
           modification_time="2016-12-3 11:41:28"
           resolution="DONE"
           status="CLOSED"
           title="Write example application"
           version="0"/>
    <tasks id="2"
           assignee_id="[null]"
           closer_id="[null]"
           creation_time="2016-12-4 11:41:28"
           creator_id="1"
           description="This lesson talks about integration testing"
           modification_time="2016-12-4 11:41:28"
           resolution="[null]"
           status="OPEN"
           title="Write lesson"
           version="0"/>
</dataset>

Após criarmos nosso dataset tasks.xml, devemos adicionar as constantes correspondentes na nossa classe de constantes de teste (Tasks) para manter o código de teste sincronizado com o dataset. Declaramos as constantes seguindo estes dois passos:

  1. Declarar as constantes que contêm os termos de busca utilizados nos testes de integração do repositório (SEARCH_TERM_ONE_MATCH com letras maiúsculas/minúsculas misturadas "Esso" para validar o case-insensitivity em "lesson", e SEARCH_TERM_NOT_FOUND).
  2. Declarar as constantes que contêm as informações das tarefas presentes no banco de dados. Como o método search() retorna apenas id, status e title das tarefas encontradas, podemos focar apenas nessas propriedades por enquanto.

Após declararmos as constantes necessárias, o código-fonte do objeto Tasks em Kotlin 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.intermediate

import com.testwithspring.intermediate.task.TaskStatus

object Tasks {

    const val SEARCH_TERM_ONE_MATCH = "Esso"
    const val SEARCH_TERM_NOT_FOUND = "NO_PE"

    object WriteExampleApp {
        const val ID = 1L
        val STATUS = TaskStatus.CLOSED
        const val TITLE = "Write example application"
    }

    object WriteLesson {
        const val ID = 2L
        val STATUS = TaskStatus.OPEN
        const val TITLE = "Write lesson"
    }
}

Nota:

O objeto Tasks completo do projeto declara outras constantes para os demais campos das tarefas (DESCRIPTION, CREATION_TIME, etc.). Como essas constantes adicionais não são necessárias nesta aula específica de repositório, nós as omitimos aqui e as adicionaremos na próxima aula, quando testarmos os controllers que exibem todos os detalhes da tarefa.

Agora, vamos configurar a nossa classe de teste de integração.


3. Configurando a Nossa Classe de Teste de Integração

Antes de escrevermos os testes de integração para o nosso método customizado de repositório, precisamos adicionar a configuração específica do teste na classe. Fazemos isso seguindo estes dois passos:

  1. Configurar o dataset do DbUnit que insere os dados de teste no banco de dados. Como o método search() apenas lê informações do banco (sem alterá-lo), todos os métodos de teste podem compartilhar o mesmo dataset anotando a classe com @DatabaseSetup("/com/testwithspring/intermediate/tasks.xml").
  2. Injetar o objeto TaskRepository testado na nossa classe de teste usando @Autowired.

Após configurarmos nossa classe de teste, seu código-fonte ficará 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
35
36
37
38
39
package com.testwithspring.intermediate.task

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.config.Profiles
import org.assertj.core.api.Assertions.assertThat
import org.springframework.beans.factory.annotation.Autowired
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
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener

@SpringBootTest
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class,
        ServletTestExecutionListener::class
    ]
)
@DatabaseSetup("/com/testwithspring/intermediate/tasks.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class SearchTasksWhenNoTasksIsFoundTest {

    @Autowired
    private lateinit var repository: TaskRepository
}

Nota: Não detalhamos novamente cada anotação de configuração da classe porque já cobrimos toda a arquitetura de configuração no Tópico 03.

Com a classe configurada, podemos finalmente escrever os testes de integração para o nosso repositório!


4. Escrevendo os Testes de Integração para o Repositório

Vamos escrever testes de integração para o método search() da interface TaskRepository. Começaremos verificando que o método funciona conforme o esperado quando nenhuma tarefa é encontrada.

Cenário 1: Nenhuma Tarefa Encontrada

Para garantir que o método testado funcione corretamente quando nenhuma tarefa corresponder ao termo pesquisado, escrevemos um teste de integração que verifica se repository.search(Tasks.SEARCH_TERM_NOT_FOUND) retorna uma lista vazia (isEmpty()).

Após escrevermos esse teste de integração, o código-fonte da nossa classe SearchTasksWhenNoTasksIsFoundTest ficará 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
35
36
37
38
39
40
41
42
43
44
45
46
package com.testwithspring.intermediate.task

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.config.Profiles
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
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
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener

@SpringBootTest
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class,
        ServletTestExecutionListener::class
    ]
)
@DatabaseSetup("/com/testwithspring/intermediate/tasks.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class SearchTasksWhenNoTasksIsFoundTest {

    @Autowired
    private lateinit var repository: TaskRepository

    @Test
    fun `deve retornar lista vazia quando nenhuma tarefa for encontrada`() {
        val tasks: List<TaskListDTO> = repository.search(Tasks.SEARCH_TERM_NOT_FOUND)
        assertThat(tasks).isEmpty()
    }
}

A seguir, verificaremos que o método search() funciona conforme o esperado quando uma única tarefa é encontrada.

Cenário 2: Uma Tarefa Encontrada

Para verificar que o método testado funciona corretamente quando exatamente uma tarefa corresponde ao termo de busca ("Esso"), devemos escrever dois testes de integração focados:

  1. Verificar que o método search() retorna uma lista contendo exatamente 1 tarefa (hasSize(1)).
  2. Garantir que o método search() retorna os dados da tarefa correta (Tasks.WriteLesson).

Após escrevermos esses dois testes de integração, o código-fonte da nossa classe SearchTasksWhenOneTaskIsFoundTest ficará 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package com.testwithspring.intermediate.task

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.config.Profiles
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
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
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener

@SpringBootTest
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class,
        ServletTestExecutionListener::class
    ]
)
@DatabaseSetup("/com/testwithspring/intermediate/tasks.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class SearchTasksWhenOneTaskIsFoundTest {

    @Autowired
    private lateinit var repository: TaskRepository

    @Test
    fun `deve retornar uma tarefa`() {
        val tasks: List<TaskListDTO> = repository.search(Tasks.SEARCH_TERM_ONE_MATCH)
        assertThat(tasks).hasSize(1)
    }

    @Test
    fun `deve retornar as informacoes da tarefa correta`() {
        val found: TaskListDTO = repository.search(Tasks.SEARCH_TERM_ONE_MATCH)[0]

        assertThat(found.id).isEqualByComparingTo(Tasks.WriteLesson.ID)
        assertThat(found.status).isEqualTo(Tasks.WriteLesson.STATUS)
        assertThat(found.title).isEqualTo(Tasks.WriteLesson.TITLE)
    }
}

Dica com @Nested no JUnit 6:

Como veremos nas aulas avançadas de JUnit 6, você também pode agrupar WhenNoTasksIsFound e WhenOneTaskIsFound (além de um terceiro cenário WhenTwoTasksAreFound buscando por "Write" para validar a ordenação ascendente pelo título!) como classes @Nested inner class dentro de uma única classe TaskRepositorySearchIntegrationTest, compartilhando a mesma configuração de contexto e dataset no topo.

Vamos resumir o que aprendemos nesta lição.


Resumo

Esta lição nos ensinou três pontos essenciais:

  • Ao escrever testes de integração para um método que consulta informações no banco de dados, devemos criar um dataset pequeno e focado que nos permita exercitar todos os casos de uso necessários (busca vazia, correspondência única, correspondência múltipla ordenada).
  • Quando o método testado apenas lê informações do banco de dados, todos os métodos de teste podem reutilizar o mesmo dataset configurado no nível da classe.
  • Obtemos a instância real do repositório testado injetando-a diretamente na nossa classe de teste via @Autowired.