Especificando uma API REST com Kotest: Leitura de Dados

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:

  1. Configuramos o mock TaskCrudService para lançar NotFoundException quando findById(99L) for invocado.
  2. Enviamos uma requisição GET /api/task/99 e guardamos o ResultActions em response.
  3. 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:

  1. Configuramos o mock TaskCrudService para retornar uma tarefa fechada com uma tag quando findById(1L) for invocado.
  2. Enviamos uma requisição GET /api/task/1 e guardamos o ResultActions em response.
  3. Verificamos que o controlador retorna o código de status HTTP 200 OK.
  4. Garantimos que o Content-Type da resposta é application/json.
  5. Verificamos via JsonPath (jsonPath(...)) que o documento JSON retornado contém todas as propriedades da tarefa encontrada.
  6. Garantimos que o array $.tags possui exatamente 1 elemento.
  7. 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.