Testes de Integração de Repositório: Consultas

Ao finalizarmos esta aula, seremos capazes de escrever testes de integração para funções de repositório que leem dados do banco de dados.

Esta aula assume que:

  • Você está familiarizado com o DbUnit (ou gerenciamento de datasets de teste com @DatabaseSetup).
  • Você está familiarizado com o JUnit Jupiter (JUnit 5 / JUnit 6.0.3).
  • Você sabe configurar o sistema sob teste ao escrever testes de integração para repositórios Spring com Kotlin e JUnit Jupiter (conforme demonstrado na aula de configuração de testes de integração de repositório).

Obtenha o código-fonte no GitHub

Introdução ao Sistema Sob Teste

Durante esta aula, escreveremos testes de integração para a função findAll() da classe TaskRepository. Esta função não recebe parâmetros e retorna uma lista de objetos TaskListItemDTO. Mais especificamente, essa função retorna uma lista de tarefas encontradas no banco de dados e ordena a lista retornada em ordem crescente utilizando o ID da tarefa como critério de ordenação.

O código-fonte da classe TaskListItemDTO é o seguinte:

1
2
3
4
5
data class TaskListItemDTO(
    val id: Long,
    val title: String,
    val status: TaskStatus
)

O código-fonte do enum TaskStatus é o seguinte:

1
2
3
4
5
enum class TaskStatus {
    CLOSED,
    IN_PROGRESS,
    OPEN
}

A seguir, descobriremos como podemos escrever testes de integração para a nossa função de repositório.

Escrevendo Testes de Integração para Nossa Função de Repositório

Podemos escrever testes de integração para a função findAll() seguindo estes passos:

Primeiro, precisamos criar uma classe de teste que contenha nossas funções de teste de integração e anotar essa classe de teste com a anotação customizada @DatabaseIntegrationTest (que configuramos na aula anterior com @SpringBootTest, @ActiveProfiles("integrationTest") e o listener do DbUnit). Depois de criar a classe de teste, devemos declarar uma propriedade privada somente leitura (private val) que contenha o objeto TaskRepository e injetar o valor dessa propriedade via injeção por construtor utilizando @Autowired.

Após realizarmos esse passo, o código-fonte da nossa classe de teste fica da seguinte forma:

1
2
3
4
5
6
7
8
import org.junit.jupiter.api.DisplayName
import org.springframework.beans.factory.annotation.Autowired

@DatabaseIntegrationTest
@DisplayName("Find all tasks from the database")
class FindAllTasksTest(@Autowired private val repository: TaskRepository) {

}

Nota sobre Injeção por Construtor em Testes Kotlin: No ecossistema moderno com Spring Boot 4.1.2 e JUnit Jupiter 6.0.3, a injeção via construtor na classe de teste é a abordagem recomendada e idiomática em Kotlin, pois permite manter o repositório como uma propriedade imutável (val) e não anulável.

Segundo, devemos garantir que o sistema sob teste funcione conforme o esperado quando nenhuma tarefa for encontrada no banco de dados. Podemos escrever o teste de integração necessário seguindo estes passos:

  1. Criar uma classe interna (inner class) anotada com @Nested que agrupe nossos testes de integração para esse cenário.
  2. Anotar a classe interna com a anotação @DatabaseIntegrationTest. Isso é necessário porque, por padrão nas suítes de teste de integração de banco de dados, queremos garantir que toda a infraestrutura do Spring TestContext e os listeners do DbUnit sejam aplicados com precisão às classes aninhadas.
  3. Inserir os dados de teste no banco de dados. Nossos datasets do DbUnit devem garantir que o banco de dados contenha usuários válidos, mas nenhuma tarefa e nenhuma tag associada.
  4. Escrever uma função de teste que verifique se a função findAll() retorna uma lista vazia.

Depois de escrevermos os testes de integração necessários, o código-fonte da nossa classe de teste 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
import com.github.springtestdbunit.annotation.DatabaseSetup
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired

@DatabaseIntegrationTest
@DisplayName("Find all tasks from the database")
class FindAllTasksTest(@Autowired private val repository: TaskRepository) {

    @Nested
    @DisplayName("When no tasks are found")
    @DatabaseIntegrationTest
    @DatabaseSetup(value = [
        "/com/testwithspring/master/users.xml",
        "/com/testwithspring/master/no-tasks-and-tags.xml"
    ])
    inner class WhenNoTasksAreFound {

        @Test
        @DisplayName("Should return an empty list")
        fun shouldReturnEmptyList() {
            val tasks = repository.findAll()
            assertThat(tasks).isEmpty()
        }
    }
}

O conjunto de dados do DbUnit que insere os dados de usuário em nosso banco de dados (users.xml) é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<dataset>
    <user_accounts id="1"
                   email_address="john.doe@gmail.com"
                   is_enabled="true"
                   name="John Doe"
                   password="user"
                   role="ROLE_USER"/>
    <user_accounts id="2"
                   email_address="anne.admin@gmail.com"
                   is_enabled="true"
                   name="Anne Admin"
                   password="admin"
                   role="ROLE_ADMIN"/>
</dataset>

O conjunto de dados do DbUnit que garante que o banco de dados não possua tarefas nem tags (no-tasks-and-tags.xml) é o seguinte:

1
2
3
4
5
<dataset>
    <tasks/>
    <tags/>
    <tasks_tags/>
</dataset>

Terceiro, devemos escrever os testes de integração que garantam que o sistema sob teste funcione como o esperado quando duas tarefas forem encontradas no banco de dados. Podemos escrever esses testes seguindo estes passos:

  1. Criar uma classe interna (inner class) anotada com @Nested para conter esses testes de integração.
  2. Anotar a classe interna com a anotação @DatabaseIntegrationTest.
  3. Garantir que possamos injetar um objeto SoftAssertions na nossa função de teste via parâmetro. Utilizaremos asserções suaves (soft assertions) ao escrever as asserções para as propriedades dos objetos retornados, garantindo que todas as falhas sejam reportadas simultaneamente caso haja divergências. Para habilitar essa injeção de parâmetros no JUnit Jupiter, registramos a extensão @ExtendWith(SoftAssertionsExtension::class).
  4. Inserir os dados de teste no banco de dados com @DatabaseSetup. Nossos datasets do DbUnit devem carregar usuários e duas tarefas com suas respectivas tags.
  5. Garantir que nossos datasets do DbUnit sejam carregados pela classe ReplacementDataSetLoader. Essa classe intercepta e converte marcadores como [null] em literais null no banco de dados. Para configurá-la, anotamos a classe com @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class).
  6. Escrever uma função de teste que verifique se a função findAll() retorna exatamente duas tarefas.
  7. Escrever uma função de teste que verifique se a função findAll() retorna a tarefa mais antiga como o primeiro elemento da lista retornada.
  8. Escrever uma função de teste que verifique se a função findAll() retorna a tarefa mais recente como o segundo elemento da lista retornada.

Depois de escrevermos nossos testes de integração, a parte relevante da nossa classe de teste 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.github.springtestdbunit.dataset.ReplacementDataSetLoader
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.SoftAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired

@DatabaseIntegrationTest
@DisplayName("Find all tasks from the database")
class FindAllTasksTest(@Autowired private val repository: TaskRepository) {

    @Nested
    @DisplayName("When two tasks are found")
    @DatabaseIntegrationTest
    @ExtendWith(SoftAssertionsExtension::class)
    @DatabaseSetup(value = [
        "/com/testwithspring/master/users.xml",
        "/com/testwithspring/master/tasks.xml"
    ])
    @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
    inner class WhenTwoTasksAreFound {

        @Test
        @DisplayName("Should return two tasks")
        fun shouldReturnTwoTasks() {
            val tasks = repository.findAll()
            assertThat(tasks).hasSize(2)
        }

        @Test
        @DisplayName("Should return the oldest task as the first task of the returned list")
        fun shouldReturnOldestTaskAsFirstTaskOfReturnedList(
            assertions: SoftAssertions
        ) {
            val firstTask = repository.findAll()[0]

            assertions.assertThat(firstTask.id)
                .`as`("id")
                .isEqualByComparingTo(Tasks.WriteExampleApplication.ID)
            assertions.assertThat(firstTask.title)
                .`as`("title")
                .isEqualTo(Tasks.WriteExampleApplication.TITLE)
            assertions.assertThat(firstTask.status)
                .`as`("status")
                .isEqualTo(Tasks.WriteExampleApplication.STATUS)
        }

        @Test
        @DisplayName("Should return the newest task as the second task of the returned list")
        fun shouldReturnNewestTaskAsSecondTaskOfReturnedList(
            assertions: SoftAssertions
        ) {
            val secondTask = repository.findAll()[1]

            assertions.assertThat(secondTask.id)
                .`as`("id")
                .isEqualByComparingTo(Tasks.WriteLesson.ID)
            assertions.assertThat(secondTask.title)
                .`as`("title")
                .isEqualTo(Tasks.WriteLesson.TITLE)
            assertions.assertThat(secondTask.status)
                .`as`("status")
                .isEqualTo(Tasks.WriteLesson.STATUS)
        }
    }
}

Leitura Adicional:

O dataset do DbUnit que insere duas tarefas no banco de dados (tasks.xml) 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
<dataset>
    <tasks id="1"
           assignee_id="1"
           closer_id="1"
           creator_id="1"
           description="This example contains integration tests"
           modifier_id="1"
           resolution="DONE"
           status="CLOSED"
           title="Write example application"/>
    <tasks id="2"
           assignee_id="[null]"
           closer_id="[null]"
           creator_id="1"
           description="This lesson talks about integration testing"
           modifier_id="1"
           resolution="[null]"
           status="OPEN"
           title="Write lesson"/>

    <tags id="1"
          name="example"/>
    <tags id="2"
          name="lesson"/>

    <tasks_tags task_id="1" tag_id="1"/>
    <tasks_tags task_id="2" tag_id="2"/>
</dataset>

A classe Tasks é uma classe utilitária de constantes que centraliza os valores das colunas usados em nosso dataset do DbUnit, facilitando a manutenção e prevenindo magic numbers ou hardcoded strings espalhadas pelos testes. Seu código-fonte em Kotlin 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
class Tasks {

    class WriteExampleApplication {

        companion object {

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

    class WriteLesson {

        companion object {

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

Agora podemos escrever testes de integração para funções de repositório que leem dados do banco de dados. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou dois conceitos essenciais:

  • Como o framework Spring TestContext historicamente não repassa por padrão todas as configurações para classes de teste aninhadas (ou caso a herança não esteja ativada via anotações como @NestedTestConfiguration), devemos anotar nossas classes aninhadas (inner class com @Nested) explicitamente com a anotação @DatabaseIntegrationTest.
  • Se quisermos escrever asserções suaves usando objetos SoftAssertions injetados diretamente como parâmetros em nossas funções de teste no JUnit Jupiter (JUnit 5 / JUnit 6.0.3), devemos utilizar a extensão SoftAssertionsExtension do AssertJ 3.27.7 (@ExtendWith(SoftAssertionsExtension::class)).