Testes de Integração de Repositórios com Kotest e Testcontainers

Depois de concluirmos esta aula, nós:

  • Saberemos escrever testes de integração para repositórios gerenciados pelo Spring utilizando o Kotest Framework.
  • Compreenderemos como devemos estruturar nossas classes de especificação quando estamos escrevendo testes de integração para repositórios Spring Data.

Esta aula assume que:

  • Você está familiarizado com o gerenciamento de estado de banco de dados com SQL e Testcontainers.
  • Você sabe configurar seus testes de integração que utilizam JUnit 6.
  • Você sabe escrever testes de integração para aplicações web Spring utilizando JUnit 6.
  • Você está familiarizado com o Kotest Framework.
  • Você sabe configurar o sistema sob especificação ao escrever testes de integração com o Kotest (SpringExtension).
  • Você sabe gerenciar o estado do banco de dados em especificações do Kotest com @Sql.

Introdução ao Sistema sob Especificação

Durante esta aula, escreveremos testes de integração para um método de repositório customizado que retorna todas as tarefas encontradas no banco de dados.

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

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

interface CustomTaskRepository {

    fun findAll(): List<TaskListDTO>

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

Nota sobre Repositórios Customizados no Spring Data JPA:

Na verdade, escreveremos testes de integração para o objeto TaskRepository. Contudo, como implementamos nossa camada de repositório com Spring Data JPA (e consultas projetadas via jOOQ / JDBC JdbcClient) e queríamos fornecer uma implementação customizada para esses dois métodos de busca, tivemos que declará-los em uma interface de repositório separada (CustomTaskRepository) que é estendida por TaskRepository.

A classe TaskListDTO contém as informações resumidas de uma única tarefa retornada pelo método findAll(). Seu código-fonte é o seguinte:

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

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

A seguir, descobriremos como podemos especificar o comportamento esperado do nosso repositório.

Especificando o Comportamento Esperado

Podemos especificar o comportamento do nosso repositório seguindo estes passos:

Primeiro, temos que criar uma nova classe de especificação e configurar o sistema sob especificação. Após termos configurado o sistema sob especificação com @DataJpaTest (ou @IntegrationTest), Testcontainers e SpringExtension, o código-fonte da nossa classe de especificação fica 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.master.task

import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.config.Profiles
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles

@SpringBootTest
@Import(PostgresTestcontainersConfiguration::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindAllTasksSpec : BehaviorSpec({

    extensions(SpringExtension(SpringTestLifecycleMode.Root))

})

Nota: Se você não sabe como configurar o sistema sob especificação no Kotest, consulte a aula: Configurando o Sistema sob Especificação com kotest-extensions-spring.

Segundo, temos que injetar o objeto TaskRepository testado na nossa classe de especificação via construtor primário. Após termos feito isso, o código-fonte da nossa classe de especificação 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
package com.testwithspring.master.task

import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.config.Profiles
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles

@SpringBootTest
@Import(PostgresTestcontainersConfiguration::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindAllTasksSpec(
    private val repository: TaskRepository
) : BehaviorSpec({

    extensions(SpringExtension(SpringTestLifecycleMode.Root))

})

Terceiro, temos que escrever um cenário que especifique o comportamento do método findAll() quando nenhuma tarefa é encontrada no banco de dados. Podemos escrever este cenário seguindo estes passos:

  1. Adicionar um novo bloco Given / When / Then à nossa classe de especificação.
  2. Garantir que nenhuma tarefa seja encontrada na tabela tasks do banco de dados executando o script no-tasks-and-tags.sql.
  3. Buscar todas as tarefas do banco de dados e armazenar as tarefas retornadas na variável tasks.
  4. Garantir que o sistema sob especificação retorne uma lista vazia (tasks.shouldBeEmpty()).

Após termos criado este novo cenário, o código-fonte da nossa classe de especificação 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
35
36
37
38
39
40
41
package com.testwithspring.master.task

import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.config.Profiles
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import io.kotest.matchers.collections.shouldBeEmpty
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.core.io.ClassPathResource
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator
import org.springframework.test.context.ActiveProfiles
import javax.sql.DataSource

@SpringBootTest
@Import(PostgresTestcontainersConfiguration::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindAllTasksSpec(
    private val repository: TaskRepository,
    private val dataSource: DataSource
) : BehaviorSpec({

    extensions(SpringExtension(SpringTestLifecycleMode.Root))

    Given("que o banco de dados não possui tarefas") {
        ResourceDatabasePopulator(
            ClassPathResource("com/testwithspring/master/no-tasks-and-tags.sql")
        ).execute(dataSource)

        When("as tarefas são consultadas no banco de dados") {
            val tasks = repository.findAll()

            Then("deve retornar uma lista vazia") {
                tasks.shouldBeEmpty()
            }
        }
    }
})

O script SQL que inicializa nosso banco de dados em um estado conhecido sem tarefas (no-tasks-and-tags.sql) é o seguinte:

1
2
3
DELETE FROM tasks_tags;
DELETE FROM tasks;
DELETE FROM tags;

Quarto, temos que escrever um cenário que especifique o comportamento do método findAll() quando duas tarefas são encontradas no banco de dados. Podemos escrever este cenário seguindo estes passos:

  1. Adicionar um novo bloco Given à nossa classe de especificação.
  2. Garantir que duas tarefas sejam encontradas na tabela tasks do banco de dados e inserir os usuários necessários na tabela user_accounts (users.sql e tasks.sql).
  3. Buscar todas as tarefas do banco de dados e armazenar as tarefas retornadas na variável tasks.
  4. Garantir que o sistema sob especificação retorne duas tarefas (tasks shouldHaveSize 2).
  5. Verificar se o sistema sob especificação retorna as informações da primeira tarefa.
  6. Garantir que o sistema sob especificação retorne as informações da segunda tarefa.

Após termos implementado nosso cenário, o código-fonte completo da nossa classe de especificação 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
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
72
73
74
75
76
77
78
79
package com.testwithspring.master.task

import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.Tasks
import com.testwithspring.master.config.Profiles
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.core.io.ClassPathResource
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator
import org.springframework.test.context.ActiveProfiles
import javax.sql.DataSource

@SpringBootTest
@Import(PostgresTestcontainersConfiguration::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindAllTasksSpec(
    private val repository: TaskRepository,
    private val dataSource: DataSource
) : BehaviorSpec({

    extensions(SpringExtension(SpringTestLifecycleMode.Root))

    Given("que o banco de dados não possui tarefas") {
        ResourceDatabasePopulator(
            ClassPathResource("com/testwithspring/master/no-tasks-and-tags.sql")
        ).execute(dataSource)

        When("as tarefas são consultadas no banco de dados") {
            val tasks = repository.findAll()

            Then("deve retornar uma lista vazia") {
                tasks.shouldBeEmpty()
            }
        }
    }

    Given("que o banco de dados possui duas tarefas") {
        ResourceDatabasePopulator(
            ClassPathResource("com/testwithspring/master/no-tasks-and-tags.sql"),
            ClassPathResource("com/testwithspring/master/users.sql"),
            ClassPathResource("com/testwithspring/master/tasks.sql")
        ).execute(dataSource)

        When("as tarefas são consultadas no banco de dados") {
            val tasks = repository.findAll()

            Then("deve retornar duas tarefas") {
                tasks shouldHaveSize 2
            }

            Then("deve retornar as informações da primeira tarefa") {
                val first = tasks[0]
                assertSoftly(first) {
                    id shouldBe Tasks.WriteExampleApp.ID
                    status shouldBe Tasks.WriteExampleApp.STATUS
                    title shouldBe Tasks.WriteExampleApp.TITLE
                }
            }

            Then("deve retornar as informações da segunda tarefa") {
                val second = tasks[1]
                assertSoftly(second) {
                    id shouldBe Tasks.WriteLesson.ID
                    status shouldBe Tasks.WriteLesson.STATUS
                    title shouldBe Tasks.WriteLesson.TITLE
                }
            }
        }
    }
})

O script SQL que insere os usuários necessários na tabela user_accounts (users.sql) é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
DELETE FROM user_accounts;

INSERT INTO user_accounts (
    id, creation_time, email_address, is_enabled, modification_time, name, password, role, version
) VALUES (
    1, '2017-02-11 11:41:28', 'john.doe@gmail.com', true, '2017-02-11 11:41:28', 'John Doe', 'user', 'ROLE_USER', 0
);

INSERT INTO user_accounts (
    id, creation_time, email_address, is_enabled, modification_time, name, password, role, version
) VALUES (
    2, '2017-02-11 11:41:28', 'anne.admin@gmail.com', true, '2017-02-11 11:41:28', 'Anne Admin', 'admin', 'ROLE_ADMIN', 0
);

O script SQL que insere duas tarefas na tabela tasks (tasks.sql) é 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
INSERT INTO tasks (
    id, assignee_id, closer_id, creation_time, creator_id, description,
    modification_time, modifier_id, resolution, status, title, version
) VALUES (
    1, 1, 1, '2016-12-03 11:41:28', 1, 'This example contains integration tests',
    '2016-12-03 11:41:28', 1, 'DONE', 'CLOSED', 'Write example application', 0
);

INSERT INTO tasks (
    id, assignee_id, closer_id, creation_time, creator_id, description,
    modification_time, modifier_id, resolution, status, title, version
) VALUES (
    2, NULL, NULL, '2016-12-04 11:41:28', 1, 'This lesson talks about integration testing',
    '2016-12-04 11:41:28', 1, NULL, 'OPEN', 'Write lesson', 0
);

INSERT INTO tags (id, creation_time, modification_time, name, version)
VALUES (1, '2016-12-03 11:41:28', '2016-12-03 11:41:28', 'example', 0);

INSERT INTO tags (id, creation_time, modification_time, name, version)
VALUES (2, '2016-12-03 11:41:28', '2016-12-03 11:41:28', 'lesson', 0);

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

Nota: O objeto Tasks é uma classe de constantes (object Tasks) que contém as informações das tarefas encontradas no nosso banco de dados, evitando strings e IDs mágicos espalhados pelas especificações.

Quando especificamos o comportamento dos nossos repositórios no Kotest, devemos seguir estas regras:

  • Se o sistema sob especificação retorna todas as linhas de uma tabela do banco de dados, devemos escrever múltiplos blocos Given (ou múltiplos métodos de feature), pois caso contrário não conseguiremos garantir que o sistema sob especificação funcione conforme o esperado tanto quando a tabela do banco de dados está vazia quanto quando ela contém dados.
  • Se o sistema sob especificação retorna todas as linhas que atendem a um critério de busca (search(searchTerm)), podemos escrever um único bloco Given com o dataset completo e testar todos os cenários necessários utilizando múltiplos blocos When e Then (por exemplo: quando nenhuma tarefa corresponde ao termo e quando tarefas correspondem ao termo).

Agora sabemos escrever testes de integração para repositórios gerenciados pelo Spring com Kotest e Testcontainers. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou três coisas:

  • Podemos injetar o objeto de repositório testado diretamente no construtor primário da nossa classe de especificação Kotest.
  • Se o sistema sob especificação retorna todas as linhas de uma tabela do banco de dados, devemos escrever múltiplos cenários de estado inicial (tabela vazia vs. tabela populada).
  • Se o sistema sob especificação retorna todas as linhas que atendem a um critério de busca, podemos inicializar o dataset uma vez no bloco Given e testar os diferentes termos de busca com múltiplos blocos When e Then.