Testes Unitários de API REST com Kotlin: Leitura

Ao concluir esta aula, compreenderemos como escrever testes unitários para uma API REST que retorna dados no formato JSON utilizando Kotlin 2.4, JUnit Jupiter 6, MockK 1.14 e Spring Boot 4.1.

[!NOTE] Esta aula assume que você:

Introdução ao Sistema Sob Teste (SUT)

Durante esta aula, escreveremos testes unitários para uma API REST do Spring MVC que retorna as informações de uma tarefa (task) no formato JSON. Quando o sistema sob teste busca as informações solicitadas, ele segue estes passos:

Primeiro, a função findById() da classe TaskCrudController obtém as informações retornadas invocando a função findById() da classe TaskCrudService.

Se a informação solicitada for encontrada, nossa API REST retorna os dados encontrados como JSON. Por outro lado, se a informação solicitada não for encontrada, nossa API REST retorna o código de status HTTP 404 (Not Found).

O código-fonte da classe TaskCrudController (compatível tanto com @RequestMapping quanto com @GetMapping no Spring Framework 7 / Spring Boot 4.1) é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import org.springframework.beans.factory.annotation.Autowired
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.RequestMethod
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/task")
open class TaskCrudController(
    @Autowired private val service: TaskCrudService
) {

    @RequestMapping(value = ["{id}"], method = [RequestMethod.GET])
    fun findById(@PathVariable("id") id: Long): TaskDTO {
        return service.findById(id)
    }
}

[!TIP] No Kotlin 2.4 com Spring Boot 4.1 e o plugin compilador kotlin("plugin.spring"), classes anotadas com @RestController ou @Service são abertas automaticamente para proxies e não exigem @Autowired explícito quando possuem um único construtor primário, nem method = [RequestMethod.GET] quando usamos @GetMapping("/{id}"). Mantemos a assinatura explícita acima para preservar total correspondência estrutural com o projeto de referência.

O modelo de dados público (DTOs) da nossa API REST é 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
25
26
27
28
enum class TaskResolution {
    DUPLICATE,
    DONE,
    WONT_DO
}

enum class TaskStatus {
    CLOSED,
    IN_PROGRESS,
    OPEN
}

data class PersonDTO(val name: String, val userId: Long)

data class TaskTagDTO(val id: Long, val name: String)

data class TaskDTO(
    val assignee: PersonDTO?,
    val closer: PersonDTO?,
    val creator: PersonDTO,
    val description: String,
    val id: Long,
    val modifier: PersonDTO,
    val resolution: TaskResolution?,
    val status: TaskStatus,
    val tags: List<TaskTagDTO>,
    val title: String
)

Segundo, a função findById() da classe TaskCrudService obtém as informações solicitadas seguindo estes passos:

  1. Invoca a função findById() da classe TaskRepository.
  2. Se nenhuma tarefa for encontrada no banco de dados (retorno null), lança uma NotFoundException.
  3. Cria um novo objeto TaskDTO utilizando as informações encontradas no banco de dados. Obtém os dados de pessoa referentes a assignee, closer, creator e modifier invocando a função findPersonInformationByUserId() da classe PersonFinder.
  4. Retorna o objeto TaskDTO criado.

O código-fonte da classe TaskCrudService é 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
open class TaskCrudService(
    @Autowired private val personFinder: PersonFinder,
    @Autowired private val repository: TaskRepository
) {

    @Transactional(readOnly = true)
    open fun findById(id: Long): TaskDTO {
        val found = repository.findById(id)
            ?: throw NotFoundException("No task was found with id: #$id")
        return TaskDTO(
            assignee = findPersonInformation(found.assignee?.id),
            closer = findPersonInformation(found.closer?.id),
            creator = personFinder
                .findPersonInformationByUserId(found.creator.id),
            description = found.description,
            id = found.id,
            modifier = personFinder
                .findPersonInformationByUserId(found.modifier.id),
            resolution = found.resolution,
            status = found.status,
            tags = transformTagsToDTOs(found.tags),
            title = found.title
        )
    }

    private fun findPersonInformation(userId: Long?): PersonDTO? {
        return if (userId == null) {
            null
        } else {
            personFinder.findPersonInformationByUserId(userId)
        }
    }

    private fun transformTagsToDTOs(models: List<TaskTag>): List<TaskTagDTO> {
        return models.map { TaskTagDTO(id = it.id, name = it.name) }
    }
}

O modelo de dados privado (domínio interno) da nossa API REST é 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
25
26
27
28
29
30
31
32
33
34
data class Assignee(val id: Long)

data class Closer(val id: Long)

data class Creator(val id: Long)

data class Modifier(val id: Long)

enum class TaskResolution {
    DUPLICATE,
    DONE,
    WONT_DO
}

enum class TaskStatus {
    CLOSED,
    IN_PROGRESS,
    OPEN
}

data class TaskTag(val id: Long, val name: String)

data class Task(
    val assignee: Assignee?,
    val closer: Closer?,
    val creator: Creator,
    val description: String,
    val id: Long,
    val modifier: Modifier,
    val resolution: TaskResolution?,
    val status: TaskStatus,
    val tags: List<TaskTag>,
    val title: String
)

Agora que estamos familiarizados com o sistema sob teste, o próximo passo é escrever uma nova função no nosso request builder.

Escrevendo uma Nova Função no Request Builder

Antes de podermos escrever os testes unitários para a nossa API REST, precisamos adicionar uma nova função à nossa classe request builder (TaskCrudRequestBuilder). Podemos escrevê-la seguindo estes passos:

  1. Adicione uma nova função findById() à nossa classe request builder. Essa função recebe um parâmetro id do tipo Long e retorna um objeto ResultActions.
  2. Envie uma requisição GET para o caminho /api/task/{id} utilizando a função perform() da classe MockMvc. Lembre-se de retornar o objeto ResultActions que é devolvido pela função perform().

Depois de adicionarmos a nova função à nossa classe request builder, seu código-fonte fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get

class TaskCrudRequestBuilder(private val mockMvc: MockMvc) {

    fun findById(id: Long): ResultActions {
        return mockMvc.perform(get("/api/task/{id}", id))
    }
}

[!TIP] Alternativa com a MockMvc Kotlin DSL: No Spring Framework 7 / Spring Boot 4.1, você também pode utilizar a função de extensão mockMvc.get("/api/task/{id}", id) do pacote org.springframework.test.web.servlet.get, que retorna ResultActionsDsl e permite encadear blocos andExpect { ... } idiomáticos em Kotlin. Ao encapsular a chamada em um Request Builder dedicado (TaskCrudRequestBuilder), retornar ResultActions (ou ResultActionsDsl) mantém os métodos de teste limpos e desacoplados dos detalhes de construção da requisição HTTP.

Vamos avançar e descobrir como escrever os testes unitários para a nossa API REST.

Escrevendo os Testes Unitários

Podemos escrever os testes unitários para a função findById() da classe TaskCrudController seguindo uma progressão estruturada em quatro etapas.

Primeiro, precisamos adicionar as constantes necessárias à nossa classe de teste. Depois de adicionarmos essas constantes no companion object da classe TaskCrudControllerTest, seu código-fonte 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
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    companion object {
        private const val ASSIGNEE_ID = 99L
        private const val ASSIGNEE_NAME = "Assignee"
        private const val CLOSER_ID = 55L
        private const val CLOSER_NAME = "Closer"
        private const val CREATOR_ID = 44L
        private const val CREATOR_NAME = "Creator"
        private const val DESCRIPTION = "This an example task"
        private const val MODIFIER_ID = 33L
        private const val MODIFIER_NAME = "Modifier"
        private val RESOLUTION_DONE = TaskResolution.DONE
        private val STATUS_CLOSED = TaskStatus.CLOSED
        private const val TAG_ID = 1L
        private const val TAG_NAME = "tag"
        private const val TASK_ID = 1L
        private const val TITLE = "A task"
    }

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {
        personFinder = mockk()
        repository = mockk()
        val service = TaskCrudService(personFinder, repository)

        val mockMvc = MockMvcBuilders
            .standaloneSetup(TaskCrudController(service))
            .setControllerAdvice(TaskErrorHandler())
            .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
            .setMessageConverters(
                WebTestConfig.objectMapperHttpMessageConverter()
            )
            .build()
        requestBuilder = TaskCrudRequestBuilder(mockMvc)
    }
}

Segundo, precisamos criar as classes internas (inner class anotadas com @Nested do JUnit Jupiter 6) que conterão nossos testes unitários. Podemos criar essas classes internas seguindo estes passos:

  1. Crie a classe interna FindById que agrupa todos os testes unitários da função findById().
  2. Crie a classe interna WhenRequestedTaskIsNotFound que contém os testes unitários que garantem que nossa API REST funciona conforme o esperado quando a tarefa solicitada não é encontrada.
  3. Crie a classe interna WhenRequestedTaskIsFound que contém os testes unitários que garantem que nossa API REST funciona conforme o esperado quando a tarefa solicitada é encontrada.

Depois de criarmos as classes internas necessárias, o código-fonte da nossa classe de teste 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
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    companion object {
        private const val ASSIGNEE_ID = 99L
        private const val ASSIGNEE_NAME = "Assignee"
        private const val CLOSER_ID = 55L
        private const val CLOSER_NAME = "Closer"
        private const val CREATOR_ID = 44L
        private const val CREATOR_NAME = "Creator"
        private const val DESCRIPTION = "This an example task"
        private const val MODIFIER_ID = 33L
        private const val MODIFIER_NAME = "Modifier"
        private val RESOLUTION_DONE = TaskResolution.DONE
        private val STATUS_CLOSED = TaskStatus.CLOSED
        private const val TAG_ID = 1L
        private const val TAG_NAME = "tag"
        private const val TASK_ID = 1L
        private const val TITLE = "A task"
    }

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {
        personFinder = mockk()
        repository = mockk()
        val service = TaskCrudService(personFinder, repository)

        val mockMvc = MockMvcBuilders
            .standaloneSetup(TaskCrudController(service))
            .setControllerAdvice(TaskErrorHandler())
            .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
            .setMessageConverters(
                WebTestConfig.objectMapperHttpMessageConverter()
            )
            .build()
        requestBuilder = TaskCrudRequestBuilder(mockMvc)
    }

    @Nested
    @DisplayName("Find a task by using its id as search criteria")
    inner class FindById {

        @Nested
        @DisplayName("When the requested task is not found")
        inner class WhenRequestedTaskIsNotFound {

        }

        @Nested
        @DisplayName("When the request task is found")
        inner class WhenRequestedTaskIsFound {

        }
    }
}

Terceiro, precisamos escrever os testes unitários que garantem que nossa API REST funciona conforme o esperado quando a tarefa solicitada não é encontrada. Podemos escrever esses testes seguindo estes passos:

  1. Escreva uma função de preparação (@BeforeEach) executada antes de cada função de teste. Essa função utiliza o MockK (every { ... } returns null) para garantir que a função findById() da classe TaskRepository retorne null quando for invocada com o parâmetro 1L (TASK_ID).
  2. Garanta que nossa API REST retorne o código de status HTTP 404 (Not Found).
  3. Verifique que nossa API REST retorne um corpo de resposta HTTP vazio ("").

Depois de escrevermos esses testes, o código-fonte da nossa classe de teste 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
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    companion object {
        private const val ASSIGNEE_ID = 99L
        private const val ASSIGNEE_NAME = "Assignee"
        private const val CLOSER_ID = 55L
        private const val CLOSER_NAME = "Closer"
        private const val CREATOR_ID = 44L
        private const val CREATOR_NAME = "Creator"
        private const val DESCRIPTION = "This an example task"
        private const val MODIFIER_ID = 33L
        private const val MODIFIER_NAME = "Modifier"
        private val RESOLUTION_DONE = TaskResolution.DONE
        private val STATUS_CLOSED = TaskStatus.CLOSED
        private const val TAG_ID = 1L
        private const val TAG_NAME = "tag"
        private const val TASK_ID = 1L
        private const val TITLE = "A task"
    }

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {
        personFinder = mockk()
        repository = mockk()
        val service = TaskCrudService(personFinder, repository)

        val mockMvc = MockMvcBuilders
            .standaloneSetup(TaskCrudController(service))
            .setControllerAdvice(TaskErrorHandler())
            .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
            .setMessageConverters(
                WebTestConfig.objectMapperHttpMessageConverter()
            )
            .build()
        requestBuilder = TaskCrudRequestBuilder(mockMvc)
    }

    @Nested
    @DisplayName("Find a task by using its id as search criteria")
    inner class FindById {

        @Nested
        @DisplayName("When the requested task is not found")
        inner class WhenRequestedTaskIsNotFound {

            @BeforeEach
            fun repositoryReturnsNull() {
                every { repository.findById(TASK_ID) } returns null
            }

            @Test
            @DisplayName("Should return the HTTP status code not found")
            fun shouldReturnHttpStatusCodeNotFound() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(status().isNotFound)
            }

            @Test
            @DisplayName("Should return an empty HTTP response")
            fun shouldReturnEmptyHttpResponse() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(content().string(""))
            }
        }

        @Nested
        @DisplayName("When the request task is found")
        inner class WhenRequestedTaskIsFound {

        }
    }
}

Quarto, precisamos escrever os testes unitários que garantem que nossa API REST funciona conforme o esperado quando a tarefa solicitada é encontrada. Podemos escrever esses testes seguindo estes passos:

  1. Escreva uma função private (repositoryReturnsFoundTask()) que garanta que a função findById() da classe TaskRepository retorne a tarefa encontrada quando for invocada passando o parâmetro 1L (TASK_ID).
  2. Escreva uma função private (personFinderReturnsRelatedPersonData()) que garanta que a função findPersonInformationByUserId() da classe PersonFinder retorne as informações de pessoa referentes a assignee, closer, creator e modifier da tarefa encontrada.
  3. Escreva uma função de preparação (@BeforeEach fun configureTestCases()) executada antes de cada função de teste. A implementação dessa função simplesmente invoca nossas duas funções utilitárias private.
  4. Garanta que nossa API REST retorne o código de status HTTP 200 (OK).
  5. Verifique que nossa API REST retorne as informações encontradas no formato JSON (MediaType.APPLICATION_JSON).
  6. Garanta que nossa API REST retorne as informações da tarefa correta ($.id).
  7. Verifique que nossa API REST retorne as informações de pessoa do responsável ($.assignee).
  8. Garanta que nossa API REST retorne as informações da pessoa que concluiu/fechou a tarefa ($.closer).
  9. Verifique que nossa API REST retorne as informações da pessoa que criou a tarefa ($.creator).
  10. Garanta que nossa API REST retorne as informações da pessoa que modificou a tarefa ($.modifier).
  11. Verifique que nossa API REST retorne a descrição ($.description) e o título ($.title) da tarefa encontrada.
  12. Garanta que nossa API REST retorne uma tarefa que foi fechada (STATUS_CLOSED) porque foi concluída (RESOLUTION_DONE).
  13. Verifique que nossa API REST retorne uma tarefa que possui exatamente uma tag ($.tags).
  14. Garanta que nossa API REST retorne as informações corretas da tag encontrada ($.tags[0].id e $.tags[0].name).

Depois de escrevermos todos os testes unitários necessários, o código-fonte completo da nossa classe de teste 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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
import io.mockk.every
import io.mockk.mockk
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.hasSize
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.http.MediaType
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

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    companion object {
        private const val ASSIGNEE_ID = 99L
        private const val ASSIGNEE_NAME = "Assignee"
        private const val CLOSER_ID = 55L
        private const val CLOSER_NAME = "Closer"
        private const val CREATOR_ID = 44L
        private const val CREATOR_NAME = "Creator"
        private const val DESCRIPTION = "This an example task"
        private const val MODIFIER_ID = 33L
        private const val MODIFIER_NAME = "Modifier"
        private val RESOLUTION_DONE = TaskResolution.DONE
        private val STATUS_CLOSED = TaskStatus.CLOSED
        private const val TAG_ID = 1L
        private const val TAG_NAME = "tag"
        private const val TASK_ID = 1L
        private const val TITLE = "A task"
    }

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {
        personFinder = mockk()
        repository = mockk()
        val service = TaskCrudService(personFinder, repository)

        val mockMvc = MockMvcBuilders
            .standaloneSetup(TaskCrudController(service))
            .setControllerAdvice(TaskErrorHandler())
            .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
            .setMessageConverters(
                WebTestConfig.objectMapperHttpMessageConverter()
            )
            .build()
        requestBuilder = TaskCrudRequestBuilder(mockMvc)
    }

    @Nested
    @DisplayName("Find a task by using its id as search criteria")
    inner class FindById {

        @Nested
        @DisplayName("When the requested task is not found")
        inner class WhenRequestedTaskIsNotFound {

            @BeforeEach
            fun repositoryReturnsNull() {
                every { repository.findById(TASK_ID) } returns null
            }

            @Test
            @DisplayName("Should return the HTTP status code not found")
            fun shouldReturnHttpStatusCodeNotFound() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(status().isNotFound)
            }

            @Test
            @DisplayName("Should return an empty HTTP response")
            fun shouldReturnEmptyHttpResponse() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(content().string(""))
            }
        }

        @Nested
        @DisplayName("When the request task is found")
        inner class WhenRequestedTaskIsFound {

            @BeforeEach
            fun configureTestCases() {
                repositoryReturnsFoundTask()
                personFinderReturnsRelatedPersonData()
            }

            private fun repositoryReturnsFoundTask() {
                every { repository.findById(TASK_ID) } returns Task(
                    assignee = Assignee(ASSIGNEE_ID),
                    closer = Closer(CLOSER_ID),
                    creator = Creator(CREATOR_ID),
                    description = DESCRIPTION,
                    id = TASK_ID,
                    modifier = Modifier(MODIFIER_ID),
                    resolution = RESOLUTION_DONE,
                    status = STATUS_CLOSED,
                    tags = listOf(TaskTag(id = TAG_ID, name = TAG_NAME)),
                    title = TITLE
                )
            }

            private fun personFinderReturnsRelatedPersonData() {
                every {
                    personFinder.findPersonInformationByUserId(ASSIGNEE_ID)
                } returns PersonDTO(name = ASSIGNEE_NAME, userId = ASSIGNEE_ID)
                every {
                    personFinder.findPersonInformationByUserId(CLOSER_ID)
                } returns PersonDTO(name = CLOSER_NAME, userId = CLOSER_ID)
                every {
                    personFinder.findPersonInformationByUserId(CREATOR_ID)
                } returns PersonDTO(name = CREATOR_NAME, userId = CREATOR_ID)
                every {
                    personFinder.findPersonInformationByUserId(MODIFIER_ID)
                } returns PersonDTO(name = MODIFIER_NAME, userId = MODIFIER_ID)
            }

            @Test
            @DisplayName("Should return the HTTP status code OK (200)")
            fun shouldReturnHttpStatusCodeOk() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(status().isOk)
            }

            @Test
            @DisplayName("Should return the information of the found task as Json")
            fun shouldReturnInformationOfFoundTaskAsJson() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            }

            @Test
            @DisplayName("Should return the information of the found task")
            fun shouldReturnInformationOfFoundTask() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(jsonPath("$.id", equalTo(TASK_ID.toInt())))
            }

            @Test
            @DisplayName("Should return the information of the assignee")
            fun shouldReturnCorrectAssignee() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(jsonPath("$.assignee.name", equalTo(ASSIGNEE_NAME)))
                    .andExpect(jsonPath("$.assignee.userId", equalTo(ASSIGNEE_ID.toInt())))
            }

            @Test
            @DisplayName("Should return the information of the person who closed the task")
            fun shouldReturnCorrectCloser() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(jsonPath("$.closer.name", equalTo(CLOSER_NAME)))
                    .andExpect(jsonPath("$.closer.userId", equalTo(CLOSER_ID.toInt())))
            }

            @Test
            @DisplayName("Should return the information of the person who created the task")
            fun shouldReturnCorrectCreator() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(jsonPath("$.creator.name", equalTo(CREATOR_NAME)))
                    .andExpect(jsonPath("$.creator.userId", equalTo(CREATOR_ID.toInt())))
            }

            @Test
            @DisplayName("Should return the information of the person who modified the task")
            fun shouldReturnCorrectModifier() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(jsonPath("$.modifier.name", equalTo(MODIFIER_NAME)))
                    .andExpect(jsonPath("$.modifier.userId", equalTo(MODIFIER_ID.toInt())))
            }

            @Test
            @DisplayName("Should return the correct description and title")
            fun shouldReturnCorrectDescriptionAndTitle() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(jsonPath("$.description", equalTo(DESCRIPTION)))
                    .andExpect(jsonPath("$.title", equalTo(TITLE)))
            }

            @Test
            @DisplayName("Should return a task that was closed because it was done")
            fun shouldReturnTaskThatIsDone() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(jsonPath("$.resolution", equalTo(RESOLUTION_DONE.name)))
                    .andExpect(jsonPath("$.status", equalTo(STATUS_CLOSED.name)))
            }

            @Test
            @DisplayName("Should return a task that has one tag")
            fun shouldReturnTaskThatHasOneTag() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(jsonPath("$.tags", hasSize<Any>(1)))
            }

            @Test
            @DisplayName("Should return the information of the found tag")
            fun shouldReturnInformationOfFoundTag() {
                requestBuilder.findById(TASK_ID)
                    .andExpect(jsonPath("$.tags[0].id", equalTo(TAG_ID.toInt())))
                    .andExpect(jsonPath("$.tags[0].name", equalTo(TAG_NAME)))
            }
        }
    }
}

[!NOTE] A desvantagem dessa abordagem baseada em expressões JsonPath individuais é que nossos testes continuarão passando mesmo se adicionarmos novos atributos inesperados ao documento JSON retornado. Consideramos esse trade-off aceitável porque a abordagem descrita nesta aula nos ajuda a escrever testes granulares, focados e muito fáceis de ler. Se você discordar dessa escolha e preferir validar a estrutura exata do payload inteiro, pode especificar o documento JSON completo esperado utilizando o método json() da classe ContentResultMatchers (content().json(..., JsonCompareMode.STRICT)).

Agora sabemos como escrever testes unitários para uma API REST que retorna dados no formato JSON usando Kotlin, JUnit Jupiter e MockK. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou três pontos fundamentais:

  • Podemos separar diferentes cenários de teste de forma hierárquica e expressiva utilizando classes internas (inner class anotadas com @Nested).
  • Se escrevermos asserções para o documento JSON retornado utilizando expressões JsonPath (jsonPath(...)), nossos testes continuarão passando caso novos atributos sejam adicionados ao documento JSON retornado.
  • Podemos especificar e validar o documento JSON completo retornado utilizando o método json() da classe ContentResultMatchers.