Testes de Integração de API REST com Kotest: Leitura

Depois de concluirmos esta aula, nós:

  • Saberemos escrever testes de integração com o Kotest para métodos controladores que leem dados do banco de dados e retornam os dados encontrados como JSON.
  • Compreenderemos como devemos estruturar nossas classes de especificação quando especificamos o comportamento do sistema sob especificação.

Esta aula assume que:

  • Você sabe gerenciar o estado do banco de dados com scripts 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.
  • Você sabe usar @Sql com o Kotest Framework.
  • Você sabe escrever testes de integração para métodos controladores que renderizam dados.

Introdução ao Sistema sob Especificação

Temos que especificar o comportamento esperado do método controlador que processa requisições GET enviadas ao caminho /api/task/{taskId}. O nome deste método é findById(), e ele simplesmente retorna as informações da tarefa encontrada no banco de dados.

O código-fonte do sistema sob especificação em Kotlin é o seguinte:

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

import com.testwithspring.master.task.TaskCrudService
import com.testwithspring.master.task.TaskDTO
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/task")
class TaskCrudRestController(
    private val crudService: TaskCrudService
) {

    @GetMapping("/{taskId}")
    fun findById(@PathVariable("taskId") taskId: Long): TaskDTO {
        return crudService.findById(taskId)
    }
}

Se uma tarefa fechada for encontrada no banco de dados e essa tarefa possuir uma tag, o método findById() retorna o seguinte documento JSON de volta para o cliente:

 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
{
    "assignee": {
        "name": "Anne Assignee",
        "userId": 44
    },
    "closer": {
        "name": "Chris Closer",
        "userId": 931
    },
    "creationTime": "2017-07-30T11:41:28+03:00[Europe/Helsinki]",
    "creator": {
        "name": "John Doe",
        "userId": 99
    },
    "description": "description",
    "id": 1,
    "modificationTime": "2017-07-31T11:41:28+03:00[Europe/Helsinki]",
    "modifier": {
        "name": "Jane Doe",
        "userId": 33
    },
    "resolution": "DONE",
    "status": "CLOSED",
    "tags": [
        {
            "id": 33,
            "name": "testing"
        }
    ],
    "title": "title"
}

Nota: Os requisitos de segurança descritos na aula Escrevendo Testes de Segurança para Controladores Spring MVC também são válidos para esta aula. Requisições não autenticadas para /api/** devem receber status HTTP 401 Unauthorized com corpo vazio.

A seguir, descobriremos como podemos especificar o comportamento esperado do sistema sob especificação.

Especificando o Comportamento Esperado

Podemos especificar o comportamento do sistema sob especificação seguindo estes passos:

Primeiro, temos que criar uma nova classe de especificação (FindTaskSpec) e configurar o sistema sob especificação e o MockMvc com springSecurity().

Após termos configurado o sistema sob especificação, 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
package com.testwithspring.master.web

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.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext

@SpringBootTest
@Import(PostgresTestcontainersConfiguration::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindTaskSpec(
    private val webAppContext: WebApplicationContext
) : BehaviorSpec({

    extensions(SpringExtension(SpringTestLifecycleMode.Root))

    lateinit var mockMvc: MockMvc

    beforeSpec {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
            .apply<org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder>(springSecurity())
            .build()
    }
})

Segundo, temos que inicializar nosso banco de dados em um estado conhecido antes que nossos cenários sejam executados. Como queremos que todos os cenários da nossa classe de especificação usem os mesmos scripts de dados, anotamos nossa classe de especificação com @Sql executando no-tasks-and-tags.sql, users.sql e tasks.sql.

O script SQL que insere os dados de teste 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 os dados de teste necessários 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);

Terceiro, temos que criar uma função auxiliar privada que recebe o ID da tarefa como parâmetro e envia uma requisição GET ao caminho /api/task/{taskId}. Esta função retorna um objeto ResultActions:

1
2
    fun findTask(id: Long): ResultActions =
        mockMvc.perform(get("/api/task/{taskId}", id))

Quarto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário anônimo tenta obter as informações de uma tarefa. Podemos escrever este cenário seguindo estes passos:

  1. Adicionar um novo bloco Given / When / Then à nossa classe de especificação.
  2. Enviar uma requisição GET ao caminho /api/task/1 e armazenar o objeto ResultActions retornado na variável response.
  3. Garantir que o método controlador testado retorne o código de status HTTP 401 (status().isUnauthorized).
  4. Verificar se o método controlador testado retorna uma resposta vazia (content().string("")).

Quinto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário registrado obtém as informações de uma tarefa. Podemos escrever este cenário seguindo estes passos:

  1. Adicionar um novo bloco Given / When / Then à nossa classe de especificação e configurar o usuário autenticado (Users.JohnDoe.EMAIL_ADDRESS).
  2. Enviar uma requisição GET ao caminho /api/task/1 e armazenar o objeto ResultActions retornado na variável response.
  3. Verificar se o método controlador testado retorna o código de status HTTP 200 (status().isOk).
  4. Garantir que o método controlador testado retorne a tarefa encontrada como JSON (MediaType.APPLICATION_JSON).
  5. Verificar se o método controlador testado retorna as informações da tarefa encontrada usando expressões jsonPath.
  6. Garantir que o método controlador testado retorne as informações de uma tag (jsonPath("$.tags", hasSize(1))).
  7. Verificar se o método controlador testado retorna as informações da tag encontrada ($.tags[0].id e $.tags[0].name).

Após termos escrito este cenário, o código-fonte completo de FindTaskSpec 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
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
package com.testwithspring.master.web

import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.Tasks
import com.testwithspring.master.Users
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.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext

@SpringBootTest
@Import(PostgresTestcontainersConfiguration::class)
@Sql(
    value = [
        "classpath:/com/testwithspring/master/no-tasks-and-tags.sql",
        "classpath:/com/testwithspring/master/users.sql",
        "classpath:/com/testwithspring/master/tasks.sql"
    ],
    executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class FindTaskSpec(
    private val webAppContext: WebApplicationContext,
    private val userDetailsService: UserDetailsService
) : BehaviorSpec({

    extensions(SpringExtension(SpringTestLifecycleMode.Root))

    lateinit var mockMvc: MockMvc

    beforeSpec {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
            .apply<org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder>(springSecurity())
            .build()
    }

    fun findTask(id: Long): ResultActions =
        mockMvc.perform(get("/api/task/{taskId}", id))

    fun findTaskAsUser(id: Long, email: String): ResultActions {
        val userDetails = userDetailsService.loadUserByUsername(email)
        return mockMvc.perform(
            get("/api/task/{taskId}", id)
                .with(user(userDetails))
        )
    }

    Given("um usuário anônimo") {
        When("o usuário anônimo tenta buscar a tarefa") {
            val response = findTask(Tasks.WriteExampleApp.ID)

            Then("deve retornar o código de status HTTP Unauthorized (401)") {
                response.andExpect(status().isUnauthorized)
            }

            Then("deve retornar uma resposta vazia") {
                response.andExpect(content().string(""))
            }
        }
    }

    Given("um usuário registrado autenticado") {
        When("a tarefa solicitada é encontrada") {
            val response = findTaskAsUser(
                Tasks.WriteExampleApp.ID,
                Users.JohnDoe.EMAIL_ADDRESS
            )

            Then("deve retornar o código de status HTTP OK (200)") {
                response.andExpect(status().isOk)
            }

            Then("deve retornar a tarefa encontrada como JSON") {
                response.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
            }

            Then("deve retornar as informações da tarefa encontrada") {
                response
                    .andExpect(jsonPath("$.assignee.userId", `is`(Tasks.WriteExampleApp.Assignee.ID.toInt())))
                    .andExpect(jsonPath("$.assignee.name", `is`(Tasks.WriteExampleApp.Assignee.NAME)))
                    .andExpect(jsonPath("$.closer.userId", `is`(Tasks.WriteExampleApp.Closer.ID.toInt())))
                    .andExpect(jsonPath("$.closer.name", `is`(Tasks.WriteExampleApp.Closer.NAME)))
                    .andExpect(jsonPath("$.creationTime", `is`(Tasks.WriteExampleApp.CREATION_TIME_STRING)))
                    .andExpect(jsonPath("$.creator.userId", `is`(Tasks.WriteExampleApp.Creator.ID.toInt())))
                    .andExpect(jsonPath("$.creator.name", `is`(Tasks.WriteExampleApp.Creator.NAME)))
                    .andExpect(jsonPath("$.id", `is`(Tasks.WriteExampleApp.ID.toInt())))
                    .andExpect(jsonPath("$.modificationTime", `is`(Tasks.WriteExampleApp.MODIFICATION_TIME_STRING)))
                    .andExpect(jsonPath("$.modifier.userId", `is`(Tasks.WriteExampleApp.Modifier.ID.toInt())))
                    .andExpect(jsonPath("$.modifier.name", `is`(Tasks.WriteExampleApp.Modifier.NAME)))
                    .andExpect(jsonPath("$.title", `is`(Tasks.WriteExampleApp.TITLE)))
                    .andExpect(jsonPath("$.description", `is`(Tasks.WriteExampleApp.DESCRIPTION)))
                    .andExpect(jsonPath("$.status", `is`(Tasks.WriteExampleApp.STATUS.toString())))
                    .andExpect(jsonPath("$.resolution", `is`(Tasks.WriteExampleApp.RESOLUTION.toString())))
            }

            Then("deve retornar uma tarefa que possui uma tag") {
                response.andExpect(jsonPath("$.tags", hasSize(1)))
            }

            Then("deve retornar as informações da tag correta") {
                response
                    .andExpect(jsonPath("$.tags[0].id", `is`(Tasks.WriteExampleApp.Tags.Example.ID.toInt())))
                    .andExpect(jsonPath("$.tags[0].name", `is`(Tasks.WriteExampleApp.Tags.Example.NAME)))
            }
        }
    }
})

Vamos avançar e descobrir como podemos estruturar nossas classes de especificação quando estamos escrevendo testes de integração para métodos controladores que leem dados do banco de dados e retornam os dados encontrados como JSON.

Estruturando Nossas Classes de Especificação

Podemos estruturar nossas classes de especificação seguindo estas regras:

  • Se o método controlador testado retorna todos os dados encontrados em uma tabela do banco de dados, devemos escrever múltiplas classes de especificação (ou múltiplos contextos Given), pois caso contrário não conseguiremos garantir que o sistema sob especificação funcione conforme o esperado quando a tabela do banco de dados está vazia e quando ela contém dados.
  • Se o método controlador testado retorna dados que atendem ao critério de busca fornecido, devemos escrever apenas uma classe de especificação. Não precisamos de múltiplas classes de especificação porque podemos construir nossos dados de teste e selecionar o critério de busca utilizado de uma forma que nos permita testar todos os cenários necessários usando múltiplos blocos When e Then.
  • Devemos escrever um cenário por papel de usuário (user role). Isso nos ajuda a minimizar o número de classes de especificação e a escrever testes de integração que possuem uma estrutura limpa e lógica.

Agora sabemos escrever testes de integração para métodos controladores que buscam dados no banco de dados e retornam os dados encontrados como JSON. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou quatro coisas:

  • Podemos autenticar requisições MockMvc no Kotest carregando o UserDetails real do banco de dados ou usando @WithUserDetails.
  • Se o método controlador testado retorna todos os dados encontrados em uma tabela do banco de dados, devemos separar os cenários de tabela vazia e tabela populada.
  • Se o método controlador testado retorna dados que atendem a um critério de busca, devemos escrever apenas uma classe de especificação.
  • Devemos escrever um cenário por papel de usuário.