Especificando uma API REST com Kotest: Escrita de Dados

Depois de concluirmos esta aula, nós:

  • Saberemos como criar especificações no Kotest 6 para endpoints de uma API REST que validam payloads JSON e escrevem dados no banco de dados.
  • Compreenderemos como verificar tanto respostas de erro de validação (400 Bad Request com fieldErrors) quanto respostas de criação bem-sucedida (201 Created).

1. Conhecendo o Sistema Sob Especificação

Precisamos especificar o comportamento do método handler que processa requisições HTTP POST enviadas para /api/task. O nome desse método é create(), e ele executa três passos:

  1. Lê os dados da tarefa a partir do corpo JSON da requisição (@RequestBody) e aplica as regras do Bean Validation (@Valid).
  2. Se houver erros de validação, o TaskErrorHandler (@ControllerAdvice) captura MethodArgumentNotValidException, retorna o status HTTP 400 Bad Request e descreve os erros de validação no corpo da resposta JSON.
  3. Caso os dados sejam válidos, cria a nova tarefa invocando crudService.create(task, loggedInUser) e retorna os dados da tarefa criada com o código de status HTTP 201 Created (@ResponseStatus(HttpStatus.CREATED)).

Veja o código-fonte do método create() em TaskCrudRestController:

 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
package com.testwithspring.master.web

import com.testwithspring.master.task.TaskCrudService
import com.testwithspring.master.task.TaskDTO
import com.testwithspring.master.task.TaskFormDTO
import com.testwithspring.master.user.LoggedInUser
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController

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

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun create(
        @Valid @RequestBody task: TaskFormDTO,
        @AuthenticationPrincipal loggedInUser: LoggedInUser?
    ): TaskDTO {
        return crudService.create(task, loggedInUser)
    }
}

Se o cliente tentar criar uma tarefa com title vazio (""), o TaskErrorHandler retornará o seguinte documento JSON com status 400 Bad Request:

1
2
3
4
5
6
7
8
{
    "fieldErrors": [
        {
            "field": "title",
            "errorCode": "NotBlank"
        }
    ]
}

E quando o método create() cria uma nova tarefa com sucesso, ele retorna o documento JSON da tarefa criada com status 201 Created:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
    "creationTime": "2026-07-30T11:41:28Z",
    "creator": {
        "name": "John Doe",
        "userId": 99
    },
    "description": "description",
    "id": 1,
    "modificationTime": "2026-07-30T11:41:28Z",
    "modifier": {
        "name": "John Doe",
        "userId": 99
    },
    "status": "OPEN",
    "tags": [],
    "title": "title"
}

Vamos construir passo a passo a nossa especificação para o endpoint create().


2. Especificando o Comportamento Esperado

Passo 1: Estrutura Inicial da Especificação

Configuramos o MockMvc em modo standalone na nossa classe TaskCrudRestControllerWriteSpec:

 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
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 TaskCrudRestControllerWriteSpec : BehaviorSpec({

    isolationMode = IsolationMode.InstancePerLeaf

    val service = mockk<TaskCrudService>()
    val mockMvc = MockMvcBuilders
        .standaloneSetup(TaskCrudRestController(service))
        .setControllerAdvice(TaskErrorHandler())
        .setLocaleResolver(fixedLocaleResolver())
        .setMessageConverters(objectMapperHttpMessageConverter())
        .build()

    // 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")
    }
}

Passo 2: Especificando o Cenário de Payload Inválido (400 Bad Request)

Precisamos garantir que o método create() funcione conforme esperado quando o usuário tenta criar uma tarefa vazia:

  1. Criamos um TaskFormDTO(title = "", description = "").
  2. Enviamos uma requisição POST /api/task com Content-Type: application/json e o payload serializado por ObjectIntoJsonConverter.convertObjectIntoJsonBytes(input).
  3. Garantimos que o controlador retorna o código de status HTTP 400 Bad Request.
  4. Verificamos que os erros de validação são retornados como JSON (application/json).
  5. Garantimos que $.fieldErrors possui exatamente 1 erro de validação.
  6. Verificamos que o erro em $.fieldErrors[0] refere-se ao campo "title" com código "NotBlank".
  7. Garantimos que service.create(...) não é invocado (verify(exactly = 0)).
 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
    Given("Create a new task - with empty title and description") {
        val input = TaskFormDTO(title = "", description = "")

        When("A user tries to create a new task by using empty title and description") {
            val response = mockMvc.perform(
                post("/api/task")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(ObjectIntoJsonConverter.convertObjectIntoJsonBytes(input))
            )

            Then("Should return the HTTP status code bad request (400)") {
                response.andExpect(status().isBadRequest)
            }

            And("Should return validation errors as JSON") {
                response.andExpect(
                    content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
                )
            }

            And("Should return one validation error") {
                response.andExpect(jsonPath("$.fieldErrors", hasSize<Any>(1)))
            }

            And("Should return validation error about an empty title") {
                response.andExpect(jsonPath("$.fieldErrors[0].field", `is`("title")))
                    .andExpect(jsonPath("$.fieldErrors[0].errorCode", `is`("NotBlank")))
            }

            And("Should not create a new task") {
                verify(exactly = 0) { service.create(any(), anyNullable()) }
            }
        }
    }

Passo 3: Especificando o Cenário de Payload Válido (201 Created)

Agora garantimos que o método create() funcione conforme esperado quando o usuário envia informações válidas:

  1. Criamos um TaskFormDTO(title = "title", description = "description") e configuramos service.create(...) para retornar o TaskDTO criado.
  2. Enviamos uma requisição POST /api/task com o corpo JSON e guardamos o ResultActions em response.
  3. Garantimos com verify(exactly = 1) que o controlador invocou service.create() passando um TaskFormDTO com id == null, description == "description" e title == "title".
  4. Verificamos que o controlador retorna o código de status HTTP 201 Created.
  5. Garantimos que a resposta é retornada como JSON (application/json).
  6. Verificamos todos os campos da tarefa criada no JSON retornado.
  7. Garantimos que a tarefa criada não possui tags ($.tags com tamanho 0).

Veja como fica a especificação completa na classe TaskCrudRestControllerWriteSpec:

  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
package com.testwithspring.master.web

import com.testwithspring.master.ObjectIntoJsonConverter
import com.testwithspring.master.TestDateTimeBuilder
import com.testwithspring.master.UnitTest
import com.testwithspring.master.task.PersonDTO
import com.testwithspring.master.task.TaskCrudService
import com.testwithspring.master.task.TaskDTOBuilder
import com.testwithspring.master.task.TaskFormDTO
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.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
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 TaskCrudRestControllerWriteSpec : BehaviorSpec({

    isolationMode = IsolationMode.InstancePerLeaf

    val service = mockk<TaskCrudService>()
    val mockMvc = MockMvcBuilders
        .standaloneSetup(TaskCrudRestController(service))
        .setControllerAdvice(TaskErrorHandler())
        .setLocaleResolver(fixedLocaleResolver())
        .setMessageConverters(objectMapperHttpMessageConverter())
        .build()

    Given("Create a new task - with empty title and description") {
        val input = TaskFormDTO(title = "", description = "")

        When("A user tries to create a new task by using empty title and description") {
            val response = mockMvc.perform(
                post("/api/task")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(ObjectIntoJsonConverter.convertObjectIntoJsonBytes(input))
            )

            Then("Should return the HTTP status code bad request") {
                response.andExpect(status().isBadRequest)
            }

            And("Should return validation errors as JSON") {
                response.andExpect(
                    content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
                )
            }

            And("Should return one validation error") {
                response.andExpect(jsonPath("$.fieldErrors", hasSize<Any>(1)))
            }

            And("Should return validation error about an empty title") {
                response.andExpect(jsonPath("$.fieldErrors[0].field", `is`("title")))
                    .andExpect(jsonPath("$.fieldErrors[0].errorCode", `is`("NotBlank")))
            }

            And("Should not create a new task") {
                verify(exactly = 0) { service.create(any(), anyNullable()) }
            }
        }
    }

    Given("Create a new task - with valid information") {
        val input = TaskFormDTO(title = "title", description = "description")

        every {
            service.create(any<TaskFormDTO>(), anyNullable())
        } returns TaskDTOBuilder()
            .withId(1L)
            .withCreationTime(CREATION_TIME)
            .withCreator(PersonDTO(userId = 99L, name = "John Doe"))
            .withDescription("description")
            .withModificationTime(CREATION_TIME)
            .withModifier(PersonDTO(userId = 99L, name = "John Doe"))
            .withTitle("title")
            .withStatusOpen()
            .build()

        When("A user creates a new task by using valid information") {
            val response = mockMvc.perform(
                post("/api/task")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(ObjectIntoJsonConverter.convertObjectIntoJsonBytes(input))
            )

            Then("Should create a new task by using the correct information") {
                verify(exactly = 1) {
                    service.create(
                        withArg { saved ->
                            saved.id shouldBe null
                            saved.description shouldBe "description"
                            saved.title shouldBe "title"
                        },
                        anyNullable()
                    )
                }
            }

            And("Should return the HTTP status code created (201)") {
                response.andExpect(status().isCreated)
            }

            And("Should return the created task as JSON") {
                response.andExpect(
                    content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
                )
            }

            And("Should return the information of the created task") {
                response.andExpect(jsonPath("$.assignee", nullValue()))
                    .andExpect(jsonPath("$.closer", nullValue()))
                    .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`(CREATION_TIME_STRING)))
                    .andExpect(jsonPath("$.modifier.userId", `is`(99)))
                    .andExpect(jsonPath("$.modifier.name", `is`("John Doe")))
                    .andExpect(jsonPath("$.title", `is`("title")))
                    .andExpect(jsonPath("$.description", `is`("description")))
                    .andExpect(jsonPath("$.status", `is`(TaskStatus.OPEN.toString())))
                    .andExpect(jsonPath("$.resolution", nullValue()))
            }

            And("Should return a task that has no tags") {
                response.andExpect(jsonPath("$.tags", hasSize<Any>(0)))
            }
        }
    }
}) {
    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")
    }
}

Resumo

Nesta aula que encerra o Tópico 03 do Capítulo 03, aprendemos dois pontos essenciais:

  • Quando o cenário de preparação é simples e direto, agrupamos cada caso de uso em seu próprio bloco Given descritivo para manter a especificação enxuta e legível.
  • Combinamos a serialização JSON de entrada (ObjectIntoJsonConverter), a inspeção de argumentos do MockK (withArg { ... }) e as asserções JsonPath (jsonPath(...)) para especificar de ponta a ponta o contrato de escrita da nossa API REST.