Testes Unitários de API REST: Leitura de Dados (GET)

Ao concluirmos esta aula, nós:

  • Seremos capazes de escrever asserções para o tipo de conteúdo (Content-Type) da resposta HTTP.
  • Saberemos como escrever asserções para o documento JSON retornado pela API utilizando expressões JsonPath combinadas com matchers do Hamcrest ou MockMvcTester.

Cenário 1: Retornando Todas as Tarefas Encontradas no Banco de Dados

Nosso primeiro exemplo analisa um endpoint REST que retorna todas as tarefas cadastradas no banco de dados. Ele utiliza a classe TaskListDTO, cujos campos são serializados em JSON quando o controlador processa uma requisição GET /api/task:

1
2
3
4
5
6
7
package com.testwithspring.starter.web

data class TaskListDTO(
    val id: Long,
    val title: String,
    val status: TaskStatus
)

O método handler findAll() da classe TaskCrudController delega a busca ao TaskCrudService e retorna a lista encontrada:

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

import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

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

    @GetMapping
    fun findAll(): List<TaskListDTO> {
        return crudService.findAll()
    }
}

A estrutura base da nossa classe de teste (TaskCrudControllerTest) está configurada da seguinte maneira:

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

import com.testwithspring.starter.web.WebTestConfig.objectMapperHttpMessageConverter
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders

class TaskCrudControllerTest {

    private val crudService: TaskCrudService = mockk()
    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        mockMvc = MockMvcBuilders.standaloneSetup(TaskCrudController(crudService))
            .setMessageConverters(objectMapperHttpMessageConverter())
            .build()
    }

    @Nested
    @DisplayName("Buscar todas as tarefas (GET /api/task)")
    inner class FindAll {
        // Os casos de teste ficarão aqui
    }
}

Precisamos escrever testes unitários para dois casos:

  1. Quando nenhuma tarefa é encontrada no banco de dados (crudService.findAll() retorna uma lista vazia).
  2. Quando duas tarefas são encontradas no banco de dados.

Vamos implementar cada um desses cenários passo a passo!

Caso 1.1: Quando Nenhuma Tarefa É Encontrada

Quando o banco de dados não possui tarefas, nosso endpoint REST deve:

  1. Retornar o código de status HTTP 200 (OK).
  2. Retornar uma resposta com o cabeçalho Content-Type igual a application/json.
  3. Retornar um array JSON vazio ([]), ou seja, uma coleção JSON com tamanho 0.

Nota sobre MediaType.APPLICATION_JSON no Spring Boot moderno: No antigo Spring Framework 4.2/4.3, usava-se MediaType.APPLICATION_JSON_UTF8. Desde o Spring Framework 5.2+ e no Spring Boot 4.1 (Spring Framework 7), APPLICATION_JSON_UTF8 foi depreciado porque a especificação oficial RFC 8259 define que UTF-8 já é o encoding padrão obrigatório de todo documento application/json. Portanto, sempre verificamos MediaType.APPLICATION_JSON!

Vamos escrever a classe aninhada WhenNoTasksAreFound passo a passo:

 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
    @Nested
    @DisplayName("Buscar todas as tarefas (GET /api/task)")
    inner class FindAll {

        @Nested
        @DisplayName("Quando nenhuma tarefa é encontrada")
        inner class WhenNoTasksAreFound {

            @BeforeEach
            fun returnEmptyTaskList() {
                every { crudService.findAll() } returns emptyList()
            }

            @Test
            @DisplayName("Deve retornar o status HTTP 200 OK")
            fun shouldReturnHttpStatusCodeOk() {
                mockMvc.perform(get("/api/task"))
                    .andExpect(status().isOk)
            }

            @Test
            @DisplayName("Deve retornar as tarefas encontradas como JSON")
            fun shouldReturnFoundTasksAsJson() {
                mockMvc.perform(get("/api/task"))
                    .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            }

            @Test
            @DisplayName("Deve retornar zero tarefas no array JSON")
            fun shouldReturnZeroTasks() {
                mockMvc.perform(get("/api/task"))
                    .andExpect(jsonPath("$", hasSize<Any>(0)))
            }
        }
    }

Observe como funcionam os dois novos matchers de resposta que utilizamos:

  • content().contentType(MediaType.APPLICATION_JSON): Verifica que o cabeçalho Content-Type da resposta HTTP é application/json.
  • jsonPath("$", hasSize<Any>(0)): A expressão JsonPath $ refere-se ao elemento raiz do documento JSON (que neste caso é um array [...]). Combinada com o matcher Matchers.hasSize(0) do Hamcrest, ela verifica que o array JSON retornado está vazio!

Caso 1.2: Quando Duas Tarefas São Encontradas

Quando o método crudService.findAll() retorna duas tarefas, o documento JSON retornado pelo controlador tem o seguinte formato:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[
  {
    "id": 1,
    "title": "Primeira tarefa",
    "status": "OPEN"
  },
  {
    "id": 2,
    "title": "Segunda tarefa",
    "status": "OPEN"
  }
]

Nossa suíte de testes para este cenário deve garantir que o endpoint:

  1. Retorne o código de status HTTP 200 (OK).
  2. Retorne o tipo de conteúdo application/json.
  3. Retorne um array JSON contendo exatamente 2 tarefas (hasSize(2)).
  4. Retorne as informações corretas da primeira tarefa no índice $[0] e da segunda tarefa no índice $[1].

Vamos implementar a classe aninhada WhenTwoTasksAreFound:

 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
        @Nested
        @DisplayName("Quando duas tarefas são encontradas")
        inner class WhenTwoTasksAreFound {

            private val firstTaskId = 1L
            private val firstTaskTitle = "Primeira tarefa"
            private val secondTaskId = 2L
            private val secondTaskTitle = "Segunda tarefa"

            @BeforeEach
            fun returnTwoTasks() {
                val first = TaskListDTO(
                    id = firstTaskId,
                    title = firstTaskTitle,
                    status = TaskStatus.OPEN
                )
                val second = TaskListDTO(
                    id = secondTaskId,
                    title = secondTaskTitle,
                    status = TaskStatus.OPEN
                )
                every { crudService.findAll() } returns listOf(first, second)
            }

            @Test
            @DisplayName("Deve retornar o status HTTP 200 OK")
            fun shouldReturnHttpStatusCodeOk() {
                mockMvc.perform(get("/api/task"))
                    .andExpect(status().isOk)
            }

            @Test
            @DisplayName("Deve retornar as tarefas encontradas como JSON")
            fun shouldReturnFoundTasksAsJson() {
                mockMvc.perform(get("/api/task"))
                    .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            }

            @Test
            @DisplayName("Deve retornar duas tarefas")
            fun shouldReturnTwoTasks() {
                mockMvc.perform(get("/api/task"))
                    .andExpect(jsonPath("$", hasSize<Any>(2)))
            }

            @Test
            @DisplayName("Deve retornar as informações das tarefas encontradas")
            fun shouldReturnInformationOfFoundTasks() {
                mockMvc.perform(get("/api/task"))
                    .andExpect(jsonPath("$[0].id", equalTo(firstTaskId.toInt())))
                    .andExpect(jsonPath("$[0].title", equalTo(firstTaskTitle)))
                    .andExpect(jsonPath("$[0].status", equalTo(TaskStatus.OPEN.name)))
                    .andExpect(jsonPath("$[1].id", equalTo(secondTaskId.toInt())))
                    .andExpect(jsonPath("$[1].title", equalTo(secondTaskTitle)))
                    .andExpect(jsonPath("$[1].status", equalTo(TaskStatus.OPEN.name)))
            }
        }

Atenção a dois detalhes importantes do JsonPath:

  1. Números Inteiros no JsonPath: Por padrão, o Jayway JsonPath desserializa números inteiros pequenos em JSON como java.lang.Integer (Int), mesmo que no lado servidor a propriedade Kotlin seja Long. Por isso, ao comparar o ID com equalTo(...) do Hamcrest, usamos firstTaskId.toInt() (ou passamos diretamente 1 / 2).
  2. Enums em JSON: O Jackson serializa enums (TaskStatus.OPEN) como String contendo o .name da constante ("OPEN"). Por isso, ao escrever a asserção com jsonPath("$[0].status", ...), devemos comparar com TaskStatus.OPEN.name ("OPEN") e não com a instância do enum!

Cenário 2: Retornando as Informações de uma Única Tarefa

Nosso segundo exemplo analisa o endpoint GET /api/task/{id}, que retorna todos os detalhes de uma tarefa específica usando a classe TaskDTO:

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

import java.time.Instant

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

Onde PersonDTO e TagDTO são definidos como:

1
2
3
4
5
6
7
8
9
data class PersonDTO(
    val userId: Long,
    val name: String
)

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

E o controlador TaskCrudController possui o método findById() e um @ExceptionHandler que retorna o status HTTP 404 (Not Found) quando a tarefa solicitada não existe:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/api/task")
class TaskCrudController(
    private val crudService: TaskCrudService
) {

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

    @ExceptionHandler(NotFoundException::class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    fun handleNotFoundError() {
    }
}

Precisamos testar dois cenários:

  1. Quando a tarefa solicitada não é encontrada (crudService.findById(taskId) lança NotFoundException).
  2. Quando a tarefa solicitada é encontrada (validando campos simples, timestamps ISO-8601, objetos aninhados e listas de tags).

Caso 2.1: Quando a Tarefa Solicitada Não É Encontrada

Quando a tarefa não existe, o endpoint deve retornar o código de status HTTP 404 (Not Found):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    @Nested
    @DisplayName("Buscar tarefa pelo ID (GET /api/task/{id})")
    inner class FindById {

        private val taskId = 99L

        @Nested
        @DisplayName("Quando a tarefa solicitada não é encontrada")
        inner class WhenRequestedTaskIsNotFound {

            @BeforeEach
            fun throwException() {
                every { crudService.findById(taskId) } throws NotFoundException("Tarefa $taskId não encontrada")
            }

            @Test
            @DisplayName("Deve retornar o status HTTP 404 Not Found")
            fun shouldReturnHttpStatusCodeNotFound() {
                mockMvc.perform(get("/api/task/{id}", taskId))
                    .andExpect(status().isNotFound)
            }
        }

Caso 2.2: Quando a Tarefa Solicitada É Encontrada

Quando a tarefa é encontrada, o endpoint retorna um documento JSON rico que inclui objetos aninhados (assignee, closer, creator, modifier), datas no padrão ISO-8601 (creationTime, modificationTime) e uma lista de tags (tags):

 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
{
  "id": 99,
  "assignee": {
    "userId": 1,
    "name": "Responsável"
  },
  "closer": {
    "userId": 2,
    "name": "Finalizador"
  },
  "creationTime": "2026-11-01T09:00:00Z",
  "creator": {
    "userId": 3,
    "name": "Criador"
  },
  "description": "Descrição detalhada da tarefa",
  "modificationTime": "2026-11-01T10:30:00Z",
  "modifier": {
    "userId": 4,
    "name": "Modificador"
  },
  "resolution": "DONE",
  "status": "CLOSED",
  "tags": [
    {
      "id": 44,
      "name": "backend"
    }
  ],
  "title": "Implementar endpoint GET"
}

Vamos escrever a classe aninhada WhenRequestedTaskIsFound testando cada grupo lógico de propriedades do JSON:

  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
        @Nested
        @DisplayName("Quando a tarefa solicitada é encontrada")
        inner class WhenRequestedTaskIsFound {

            private val assigneeId = 1L
            private val assigneeName = "Responsável"
            private val closerId = 2L
            private val closerName = "Finalizador"
            private val creationTimeString = "2026-11-01T09:00:00Z"
            private val creationTime = Instant.parse(creationTimeString)
            private val creatorId = 3L
            private val creatorName = "Criador"
            private val description = "Descrição detalhada da tarefa"
            private val modificationTimeString = "2026-11-01T10:30:00Z"
            private val modificationTime = Instant.parse(modificationTimeString)
            private val modifierId = 4L
            private val modifierName = "Modificador"
            private val tagId = 44L
            private val tagName = "backend"
            private val title = "Implementar endpoint GET"

            @BeforeEach
            fun returnFoundTask() {
                val found = TaskDTO(
                    id = taskId,
                    assignee = PersonDTO(userId = assigneeId, name = assigneeName),
                    closer = PersonDTO(userId = closerId, name = closerName),
                    creationTime = creationTime,
                    creator = PersonDTO(userId = creatorId, name = creatorName),
                    description = description,
                    modificationTime = modificationTime,
                    modifier = PersonDTO(userId = modifierId, name = modifierName),
                    resolution = TaskResolution.DONE,
                    status = TaskStatus.CLOSED,
                    tags = listOf(TagDTO(id = tagId, name = tagName)),
                    title = title
                )

                every { crudService.findById(taskId) } returns found
            }

            @Test
            @DisplayName("Deve retornar o status HTTP 200 OK")
            fun shouldReturnHttpStatusCodeOk() {
                mockMvc.perform(get("/api/task/{id}", taskId))
                    .andExpect(status().isOk)
            }

            @Test
            @DisplayName("Deve retornar a tarefa encontrada como JSON")
            fun shouldReturnFoundTaskAsJson() {
                mockMvc.perform(get("/api/task/{id}", taskId))
                    .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            }

            @Test
            @DisplayName("Deve retornar as informações do responsável e do finalizador")
            fun shouldReturnInformationOfAssigneeAndCloser() {
                mockMvc.perform(get("/api/task/{id}", taskId))
                    .andExpect(jsonPath("$.assignee.userId", equalTo(assigneeId.toInt())))
                    .andExpect(jsonPath("$.assignee.name", equalTo(assigneeName)))
                    .andExpect(jsonPath("$.closer.userId", equalTo(closerId.toInt())))
                    .andExpect(jsonPath("$.closer.name", equalTo(closerName)))
            }

            @Test
            @DisplayName("Deve retornar as informações de auditoria de criação")
            fun shouldReturnCreationInformation() {
                mockMvc.perform(get("/api/task/{id}", taskId))
                    .andExpect(jsonPath("$.creationTime", equalTo(creationTimeString)))
                    .andExpect(jsonPath("$.creator.userId", equalTo(creatorId.toInt())))
                    .andExpect(jsonPath("$.creator.name", equalTo(creatorName)))
            }

            @Test
            @DisplayName("Deve retornar o ID, título e descrição da tarefa")
            fun shouldReturnIdTitleAndDescription() {
                mockMvc.perform(get("/api/task/{id}", taskId))
                    .andExpect(jsonPath("$.id", equalTo(taskId.toInt())))
                    .andExpect(jsonPath("$.title", equalTo(title)))
                    .andExpect(jsonPath("$.description", equalTo(description)))
            }

            @Test
            @DisplayName("Deve retornar as informações de auditoria de modificação")
            fun shouldReturnModificationInformation() {
                mockMvc.perform(get("/api/task/{id}", taskId))
                    .andExpect(jsonPath("$.modificationTime", equalTo(modificationTimeString)))
                    .andExpect(jsonPath("$.modifier.userId", equalTo(modifierId.toInt())))
                    .andExpect(jsonPath("$.modifier.name", equalTo(modifierName)))
            }

            @Test
            @DisplayName("Deve retornar o status e a resolução da tarefa")
            fun shouldReturnStatusAndResolution() {
                mockMvc.perform(get("/api/task/{id}", taskId))
                    .andExpect(jsonPath("$.resolution", equalTo(TaskResolution.DONE.name)))
                    .andExpect(jsonPath("$.status", equalTo(TaskStatus.CLOSED.name)))
            }

            @Test
            @DisplayName("Deve retornar a lista de tags da tarefa")
            fun shouldReturnTagsOfTask() {
                mockMvc.perform(get("/api/task/{id}", taskId))
                    .andExpect(jsonPath("$.tags", hasSize<Any>(1)))
                    .andExpect(jsonPath("$.tags[0].id", equalTo(tagId.toInt())))
                    .andExpect(jsonPath("$.tags[0].name", equalTo(tagName)))
            }
        }
    }

Resumo

Esta aula nos ensinou três coisas fundamentais:

  • Podemos verificar o tipo de conteúdo (Content-Type) da resposta HTTP usando o método content().contentType(MediaType.APPLICATION_JSON).
  • Podemos escrever asserções para documentos JSON retornados pela API usando o método MockMvcResultMatchers.jsonPath(...), que avalia expressões JsonPath (como $, $[0].title, $.creator.name e $.tags[0].id) combinadas com matchers do Hamcrest.
  • Quando escrevemos asserções JsonPath para valores de enums serializados pelo Jackson, devemos comparar o valor do atributo JSON com a representação textual (enumValue.name) e não com o objeto enum em si.