Depois de concluirmos esta aula, nós:
- Saberemos como criar especificações no Kotest 6 para métodos de controladores REST que leem dados do banco de dados e retornam o resultado serializado como JSON.
- Compreenderemos como testar datas e horários (
ZonedDateTime) de forma independente do fuso horário da máquina onde os testes são executados (TestDateTimeBuilder).
1. Conhecendo o Sistema Sob Especificação
Precisamos especificar o comportamento esperado do endpoint que processa requisições HTTP GET enviadas para o caminho /api/task/{taskId}. O nome desse método é findById(), e ele simplesmente retorna o TaskDTO retornado pelo TaskCrudService:
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 (CLOSED) for encontrada e possuir uma tag, o método findById() retornará o seguinte documento JSON 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": "2026-07-30T11:41:28Z",
"creator": {
"name": "John Doe",
"userId": 99
},
"description": "description",
"id": 1,
"modificationTime": "2026-07-31T11:41:28Z",
"modifier": {
"name": "Jane Doe",
"userId": 33
},
"resolution": "DONE",
"status": "CLOSED",
"tags": [
{
"id": 33,
"name": "testing"
}
],
"title": "title"
}
|
Vamos construir passo a passo a nossa especificação para o endpoint findById().
2. Especificando o Comportamento Esperado
Passo 1: Configurando a Especificação e as Constantes de Data/Hora
Primeiro, configuramos o MockMvc em modo standalone com TaskErrorHandler(), fixedLocaleResolver() e objectMapperHttpMessageConverter(), e declaramos as constantes de data e hora usando TestDateTimeBuilder:
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
|
package com.testwithspring.master.web
import com.testwithspring.master.TestDateTimeBuilder
import com.testwithspring.master.UnitTest
import com.testwithspring.master.task.TaskCrudService
import com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
import com.testwithspring.master.web.WebTestConfig.objectMapperHttpMessageConverter
import io.kotest.core.annotation.Tags
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.BehaviorSpec
import io.mockk.mockk
import org.springframework.test.web.servlet.setup.MockMvcBuilders
@Tags("UnitTest")
class TaskCrudRestControllerReadSpec : BehaviorSpec({
isolationMode = IsolationMode.InstancePerLeaf
val service = mockk<TaskCrudService>()
val mockMvc = MockMvcBuilders
.standaloneSetup(TaskCrudRestController(service))
.setControllerAdvice(TaskErrorHandler())
.setLocaleResolver(fixedLocaleResolver())
.setMessageConverters(objectMapperHttpMessageConverter())
.build()
Given("Find one task") {
// Cenários virão a seguir
}
}) {
companion object {
private val CREATION_TIME = TestDateTimeBuilder
.parseLocalDateTimeFromUTCDateTime("2026-07-30T11:41:28")
private val CREATION_TIME_STRING = TestDateTimeBuilder
.transformUTCDateToLocalDateTime("2026-07-30T11:41:28")
private val MODIFICATION_TIME = TestDateTimeBuilder
.parseLocalDateTimeFromUTCDateTime("2026-07-31T11:41:28")
private val MODIFICATION_TIME_STRING = TestDateTimeBuilder
.transformUTCDateToLocalDateTime("2026-07-31T11:41:28")
}
}
|
[!NOTE]
Por que usar TestDateTimeBuilder?
A classe TestDateTimeBuilder fornece métodos factory que criam objetos ZonedDateTime e suas respectivas representações String ISO-8601 usando o fuso horário padrão do sistema operacional onde o teste está rodando. Isso garante que nossos testes unitários passem em máquinas de desenvolvedores e servidores de CI em qualquer fuso horário do mundo sem precisar alterar os dados de teste!
Passo 2: Especificando o Cenário em que a Tarefa Não É Encontrada (404)
Precisamos verificar que o método findById() funciona corretamente quando a tarefa solicitada não é encontrada:
- Configuramos o mock
TaskCrudService para lançar NotFoundException quando findById(99L) for invocado.
- Enviamos uma requisição
GET /api/task/99 e guardamos o ResultActions em response.
- Verificamos que o controlador (em conjunto com o
TaskErrorHandler) retorna o código de status HTTP 404 Not Found.
1
2
3
4
5
6
7
8
9
10
11
|
Given("Find one task - when no task is found") {
every { service.findById(99L) } throws NotFoundException("")
When("A user tries to get the task with id 99") {
val response = mockMvc.perform(get("/api/task/{taskId}", 99L))
Then("Should return the HTTP status code not found (404)") {
response.andExpect(status().isNotFound)
}
}
}
|
Passo 3: Especificando o Cenário em que a Tarefa É Encontrada (200 OK)
Agora verificamos que o método findById() funciona como esperado quando a tarefa solicitada é encontrada:
- Configuramos o mock
TaskCrudService para retornar uma tarefa fechada com uma tag quando findById(1L) for invocado.
- Enviamos uma requisição
GET /api/task/1 e guardamos o ResultActions em response.
- Verificamos que o controlador retorna o código de status HTTP
200 OK.
- Garantimos que o
Content-Type da resposta é application/json.
- Verificamos via JsonPath (
jsonPath(...)) que o documento JSON retornado contém todas as propriedades da tarefa encontrada.
- Garantimos que o array
$.tags possui exatamente 1 elemento.
- Verificamos que
$.tags[0] contém id = 33 e name = "testing".
Veja como fica a classe TaskCrudRestControllerReadSpec completa:
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
|
package com.testwithspring.master.web
import com.testwithspring.master.NotFoundException
import com.testwithspring.master.TestDateTimeBuilder
import com.testwithspring.master.UnitTest
import com.testwithspring.master.task.PersonDTO
import com.testwithspring.master.task.TagDTO
import com.testwithspring.master.task.TaskCrudService
import com.testwithspring.master.task.TaskDTOBuilder
import com.testwithspring.master.task.TaskResolution
import com.testwithspring.master.task.TaskStatus
import com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
import com.testwithspring.master.web.WebTestConfig.objectMapperHttpMessageConverter
import io.kotest.core.annotation.Tags
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.BehaviorSpec
import io.mockk.every
import io.mockk.mockk
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.springframework.http.MediaType
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
@Tags("UnitTest")
class TaskCrudRestControllerReadSpec : BehaviorSpec({
isolationMode = IsolationMode.InstancePerLeaf
val service = mockk<TaskCrudService>()
val mockMvc = MockMvcBuilders
.standaloneSetup(TaskCrudRestController(service))
.setControllerAdvice(TaskErrorHandler())
.setLocaleResolver(fixedLocaleResolver())
.setMessageConverters(objectMapperHttpMessageConverter())
.build()
Given("Find one task - when no task is found") {
every { service.findById(99L) } throws NotFoundException("")
When("A user tries to get the task") {
val response = mockMvc.perform(get("/api/task/{taskId}", 99L))
Then("Should return the HTTP status code not found") {
response.andExpect(status().isNotFound)
}
}
}
Given("Find one task - when a closed task is found and it has one tag") {
every { service.findById(1L) } returns TaskDTOBuilder()
.withId(1L)
.withAssignee(PersonDTO(userId = 44L, name = "Anne Assignee"))
.withCreationTime(CREATION_TIME)
.withCreator(PersonDTO(userId = 99L, name = "John Doe"))
.withDescription("description")
.withModificationTime(MODIFICATION_TIME)
.withModifier(PersonDTO(userId = 33L, name = "Jane Doe"))
.withResolutionDone(PersonDTO(userId = 931L, name = "Chris Closer"))
.withTags(TagDTO(id = 33L, name = "testing"))
.withTitle("title")
.build()
When("A user gets the task") {
val response = mockMvc.perform(get("/api/task/{taskId}", 1L))
Then("Should return the HTTP status code OK") {
response.andExpect(status().isOk)
}
And("Should return the found task as JSON") {
response.andExpect(
content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
)
}
And("Should return the information of the found task") {
response.andExpect(jsonPath("$.assignee.userId", `is`(44)))
.andExpect(jsonPath("$.assignee.name", `is`("Anne Assignee")))
.andExpect(jsonPath("$.closer.userId", `is`(931)))
.andExpect(jsonPath("$.closer.name", `is`("Chris Closer")))
.andExpect(jsonPath("$.creationTime", `is`(CREATION_TIME_STRING)))
.andExpect(jsonPath("$.creator.userId", `is`(99)))
.andExpect(jsonPath("$.creator.name", `is`("John Doe")))
.andExpect(jsonPath("$.id", `is`(1)))
.andExpect(jsonPath("$.modificationTime", `is`(MODIFICATION_TIME_STRING)))
.andExpect(jsonPath("$.modifier.userId", `is`(33)))
.andExpect(jsonPath("$.modifier.name", `is`("Jane Doe")))
.andExpect(jsonPath("$.title", `is`("title")))
.andExpect(jsonPath("$.description", `is`("description")))
.andExpect(jsonPath("$.status", `is`(TaskStatus.CLOSED.toString())))
.andExpect(jsonPath("$.resolution", `is`(TaskResolution.DONE.toString())))
}
And("Should return a task that has one tag") {
response.andExpect(jsonPath("$.tags", hasSize<Any>(1)))
}
And("Should return the tag of the found task") {
response.andExpect(jsonPath("$.tags[0].id", `is`(33)))
.andExpect(jsonPath("$.tags[0].name", `is`("testing")))
}
}
}
}) {
companion object {
private val CREATION_TIME = TestDateTimeBuilder
.parseLocalDateTimeFromUTCDateTime("2026-07-30T11:41:28")
private val CREATION_TIME_STRING = TestDateTimeBuilder
.transformUTCDateToLocalDateTime("2026-07-30T11:41:28")
private val MODIFICATION_TIME = TestDateTimeBuilder
.parseLocalDateTimeFromUTCDateTime("2026-07-31T11:41:28")
private val MODIFICATION_TIME_STRING = TestDateTimeBuilder
.transformUTCDateToLocalDateTime("2026-07-31T11:41:28")
}
}
|
Resumo
Nesta aula, aprendemos quatro pontos essenciais:
- Especificamos diferentes cenários de leitura da nossa API REST estruturando blocos
Given, When e Then.
- Dividimos o bloco
Then em seções And dedicadas para validar separadamente o status HTTP, o Content-Type JSON, os atributos escalares/aninhados da tarefa e a coleção tags.
- Descrições claras transformam nossa especificação do Kotest em documentação executável do contrato JSON da API.
- Quando nossa API REST retorna datas e horários, construímos os dados de teste usando o fuso horário padrão do sistema (
TestDateTimeBuilder) para que os testes passem em qualquer fuso horário.