Ao concluirmos esta aula, nós:
- Saberemos como converter objetos em payloads JSON (bytes ou strings) utilizando nosso
ObjectMapper customizado.
- Compreenderemos como enviar documentos JSON no corpo da requisição HTTP (
@RequestBody) para o controlador sob teste.
- Saberemos como escrever asserções resilientes para os erros de validação retornados ao cliente, levando em conta que os erros de validação não possuem ordem garantida.
Introdução ao Sistema Sob Teste (SUT)
Durante esta aula, escreveremos testes unitários para um endpoint REST (POST /api/task) que cria uma nova tarefa no sistema. O método handler recebe no corpo da requisição um documento JSON que é desserializado na classe TaskFormDTO:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
package com.testwithspring.starter.web
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class TaskFormDTO(
val id: Long? = null,
@field:Size(max = 500)
val description: String? = null,
@field:NotBlank
@field:Size(max = 100)
val title: String? = null
)
|
O controlador TaskCrudController processa requisições POST /api/task no método create():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
package com.testwithspring.starter.web
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
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 TaskCrudController(
private val crudService: TaskCrudService
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(@RequestBody @Valid task: TaskFormDTO): TaskDTO {
return crudService.create(task)
}
}
|
E como os erros de validação são tratados quando o objeto TaskFormDTO viola alguma anotação como @NotBlank ou @Size?
Quando a validação falha em um parâmetro anotado com @RequestBody @Valid, o Spring MVC lança uma exceção MethodArgumentNotSupportedException (ou MethodArgumentNotValidException). Na nossa aplicação, a classe RestErrorHandler anotada com @ControllerAdvice captura essa exceção, retorna o status HTTP 400 (Bad Request) e serializa os erros de campo em um ValidationErrorDTO:
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
|
package com.testwithspring.starter.web
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.MessageSource
import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.http.HttpStatus
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.ResponseStatus
@ControllerAdvice
class RestErrorHandler @Autowired constructor(
private val messageSource: MessageSource
) {
@ExceptionHandler(MethodArgumentNotValidException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
fun processValidationError(ex: MethodArgumentNotValidException): ValidationErrorDTO {
val result = ex.bindingResult
val fieldErrors = result.fieldErrors
return processFieldErrors(fieldErrors)
}
private fun processFieldErrors(fieldErrors: List<FieldError>): ValidationErrorDTO {
val dto = ValidationErrorDTO()
for (fieldError in fieldErrors) {
val localizedErrorMessage = resolveLocalizedErrorMessage(fieldError)
dto.addFieldError(fieldError.field, localizedErrorMessage)
}
return dto
}
private fun resolveLocalizedErrorMessage(fieldError: FieldError): String {
val currentLocale = LocaleContextHolder.getLocale()
val localizedErrorMessage = messageSource.getMessage(fieldError, currentLocale)
// Se a mensagem não for encontrada no MessageSource, usa o código do erro (ex: "NotBlank", "Size")
return if (localizedErrorMessage == fieldError.defaultMessage) {
val fieldErrorCodes = fieldError.codes
fieldErrorCodes?.get(0) ?: fieldError.code ?: "ValidationError"
} else {
localizedErrorMessage
}
}
}
|
As classes ValidationErrorDTO e FieldErrorDTO geram a seguinte estrutura JSON quando há erros de validação:
1
2
3
4
5
6
7
8
|
{
"fieldErrors": [
{
"field": "title",
"errorCode": "NotBlank"
}
]
}
|
A configuração da nossa classe de teste (TaskCrudControllerTest) registra tanto o TaskCrudController quanto o @ControllerAdvice (RestErrorHandler) e um FixedLocaleResolver:
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
|
package com.testwithspring.starter.web
import com.testwithspring.starter.web.WebTestConfig.fixedLocaleResolver
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.context.support.StaticMessageSource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.util.Locale
class TaskCrudControllerTest {
companion object {
private const val ERROR_CODE_EMPTY_TITLE = "NotBlank"
private const val ERROR_CODE_TOO_LONG_DESCRIPTION = "Size"
private const val ERROR_CODE_TOO_LONG_TITLE = "Size"
private const val FIELD_NAME_DESCRIPTION = "description"
private const val FIELD_NAME_TITLE = "title"
}
private val crudService: TaskCrudService = mockk(relaxed = true)
private lateinit var messageSource: StaticMessageSource
private lateinit var mockMvc: MockMvc
@BeforeEach
fun configureSystemUnderTest() {
messageSource = StaticMessageSource()
mockMvc = MockMvcBuilders.standaloneSetup(TaskCrudController(crudService))
.setControllerAdvice(RestErrorHandler(messageSource))
.setLocaleResolver(fixedLocaleResolver(Locale.ENGLISH))
.setMessageConverters(objectMapperHttpMessageConverter())
.build()
}
@Nested
@DisplayName("Criar uma nova tarefa (POST /api/task)")
inner class Create {
// Os casos de teste ficarão aqui
}
}
|
Antes de escrevermos nossos testes, precisamos resolver uma questão prática: como convertemos nossos objetos TaskFormDTO em um payload JSON para enviá-los no corpo da requisição POST?
Convertendo Objetos em JSON
Para enviar um objeto TaskFormDTO no corpo de uma requisição HTTP usando o MockMvc, precisamos serializá-lo em um array de bytes JSON (ByteArray) ou String.
Podemos criar uma função utilitária em WebTestUtil que utiliza exatamente o mesmo ObjectMapper configurado em WebTestConfig.objectMapper():
1
2
3
4
5
6
7
8
9
|
package com.testwithspring.starter.web
object WebTestUtil {
fun convertObjectToJsonBytes(model: Any): ByteArray {
val objectMapper = WebTestConfig.objectMapper()
return objectMapper.writeValueAsBytes(model)
}
}
|
Agora podemos enviar uma requisição POST /api/task com corpo JSON configurando dois métodos do MockHttpServletRequestBuilder:
.contentType(MediaType.APPLICATION_JSON): Informa ao Spring MVC que o corpo da requisição é um documento JSON.
.content(WebTestUtil.convertObjectToJsonBytes(input)): Escreve os bytes do documento JSON no corpo da requisição.
Vamos agora escrever os testes unitários para quatro cenários do endpoint de criação de tarefas!
Quando o cliente envia um documento JSON com title = "" e description = "", a nossa API REST deve:
- Retornar o código de status HTTP 400 (
Bad Request).
- Retornar os erros de validação como um documento JSON (
Content-Type: application/json).
- Retornar exatamente 1 erro de validação para o campo
title com o código "NotBlank".
- Não criar uma nova tarefa (
verify(exactly = 0) { crudService.create(any()) }).
Vamos implementar a classe aninhada WhenNewTaskIsCreatedWithEmptyInformation:
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("Criar uma nova tarefa (POST /api/task)")
inner class Create {
@Nested
@DisplayName("Quando uma tarefa é criada com dados vazios")
inner class WhenNewTaskIsCreatedWithEmptyInformation {
private lateinit var input: TaskFormDTO
@BeforeEach
fun createEmptyTask() {
input = TaskFormDTO(
title = "",
description = ""
)
}
private fun createTaskWithEmptyInformation(): ResultActions {
return mockMvc.perform(
post("/api/task")
.contentType(MediaType.APPLICATION_JSON)
.content(WebTestUtil.convertObjectToJsonBytes(input))
)
}
@Test
@DisplayName("Deve retornar o status HTTP 400 Bad Request")
fun shouldReturnHttpStatusCodeBadRequest() {
createTaskWithEmptyInformation()
.andExpect(status().isBadRequest)
}
@Test
@DisplayName("Deve retornar os erros de validação como JSON")
fun shouldReturnValidationErrorsAsJson() {
createTaskWithEmptyInformation()
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
}
@Test
@DisplayName("Deve retornar um erro de validação sobre o título em branco")
fun shouldReturnValidationErrorAboutEmptyTitle() {
createTaskWithEmptyInformation()
.andExpect(jsonPath("$.fieldErrors", hasSize<Any>(1)))
.andExpect(jsonPath("$.fieldErrors[0].field", equalTo(FIELD_NAME_TITLE)))
.andExpect(jsonPath("$.fieldErrors[0].errorCode", equalTo(ERROR_CODE_EMPTY_TITLE)))
}
@Test
@DisplayName("Não deve criar uma nova tarefa")
fun shouldNotCreateNewTask() {
createTaskWithEmptyInformation()
verify(exactly = 0) { crudService.create(any()) }
}
}
|
Quando o cliente envia um documento JSON cujo title excede 100 caracteres e description excede 500 caracteres, dois erros de validação são gerados pelo Jakarta Bean Validation:
1
2
3
4
5
6
7
8
9
10
11
12
|
{
"fieldErrors": [
{
"field": "description",
"errorCode": "Size"
},
{
"field": "title",
"errorCode": "Size"
}
]
}
|
Cuidado com a Ordem dos Erros de Validação! A Jakarta Bean Validation (Hibernate Validator) valida as propriedades usando reflexão e não garante a ordem em que os erros de campo (FieldError) são retornados! Em uma execução o erro de title pode vir em $.fieldErrors[0], e em outra pode vir em $.fieldErrors[1].
Se escrevermos asserções fixas por índice ($.fieldErrors[0].field == "title"), teremos um flaky test (teste intermitente). Para resolver isso de forma determinística, usamos as expressões de projeção do JsonPath ($.fieldErrors[*].field e $.fieldErrors[*].errorCode) combinadas com o matcher Matchers.containsInAnyOrder(...) do Hamcrest!
Veja como implementamos a classe aninhada WhenNewTaskIsCreatedWithTooLongTitleAndDescription:
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
|
@Nested
@DisplayName("Quando uma tarefa é criada com título e descrição longos demais")
inner class WhenNewTaskIsCreatedWithTooLongTitleAndDescription {
private lateinit var input: TaskFormDTO
@BeforeEach
fun createTaskWithTooLongTitleAndDescription() {
input = TaskFormDTO(
title = "t".repeat(101),
description = "d".repeat(501)
)
}
private fun executeRequest(): ResultActions {
return mockMvc.perform(
post("/api/task")
.contentType(MediaType.APPLICATION_JSON)
.content(WebTestUtil.convertObjectToJsonBytes(input))
)
}
@Test
@DisplayName("Deve retornar o status HTTP 400 Bad Request")
fun shouldReturnHttpStatusCodeBadRequest() {
executeRequest()
.andExpect(status().isBadRequest)
}
@Test
@DisplayName("Deve retornar os erros de validação como JSON")
fun shouldReturnValidationErrorsAsJson() {
executeRequest()
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
}
@Test
@DisplayName("Deve retornar dois erros de validação sobre o tamanho do título e da descrição")
fun shouldReturnValidationErrorsAboutTooLongTitleAndDescription() {
executeRequest()
.andExpect(jsonPath("$.fieldErrors", hasSize<Any>(2)))
.andExpect(
jsonPath(
"$.fieldErrors[*].field",
containsInAnyOrder(FIELD_NAME_DESCRIPTION, FIELD_NAME_TITLE)
)
)
.andExpect(
jsonPath(
"$.fieldErrors[*].errorCode",
containsInAnyOrder(ERROR_CODE_TOO_LONG_DESCRIPTION, ERROR_CODE_TOO_LONG_TITLE)
)
)
}
@Test
@DisplayName("Não deve criar uma nova tarefa")
fun shouldNotCreateNewTask() {
executeRequest()
verify(exactly = 0) { crudService.create(any()) }
}
}
|
Quando o cliente envia um documento JSON válido (title e description dentro dos limites), nossa API REST deve:
- Retornar o código de status HTTP 201 (
Created).
- Retornar a tarefa recém-criada como um documento JSON (
Content-Type: application/json) contendo todos os atributos retornados pelo crudService.create(...).
- Invocar
crudService.create(...) passando um objeto TaskFormDTO com id = null, title correto e description correta.
Vamos implementar a classe aninhada WhenNewTaskIsCreatedWithValidInformation:
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
|
@Nested
@DisplayName("Quando uma tarefa é criada com informações válidas")
inner class WhenNewTaskIsCreatedWithValidInformation {
private val createdTaskId = 99L
private val creatorId = 3L
private val creatorName = "Criador"
private val creationTimeString = "2026-11-01T09:00:00Z"
private val creationTime = Instant.parse(creationTimeString)
private val description = "Descrição válida da tarefa"
private val modificationTimeString = "2026-11-01T09:00:00Z"
private val modificationTime = Instant.parse(modificationTimeString)
private val modifierId = 3L
private val modifierName = "Criador"
private val title = "Título válido da tarefa"
private lateinit var input: TaskFormDTO
@BeforeEach
fun configureSystemUnderTest() {
input = TaskFormDTO(
title = title,
description = description
)
val created = TaskDTO(
id = createdTaskId,
assignee = null,
closer = null,
creationTime = creationTime,
creator = PersonDTO(userId = creatorId, name = creatorName),
description = description,
modificationTime = modificationTime,
modifier = PersonDTO(userId = modifierId, name = modifierName),
resolution = null,
status = TaskStatus.OPEN,
tags = emptyList(),
title = title
)
every { crudService.create(any()) } returns created
}
private fun createTaskWithValidInformation(): ResultActions {
return mockMvc.perform(
post("/api/task")
.contentType(MediaType.APPLICATION_JSON)
.content(WebTestUtil.convertObjectToJsonBytes(input))
)
}
@Test
@DisplayName("Deve retornar o status HTTP 201 Created")
fun shouldReturnHttpStatusCodeCreated() {
createTaskWithValidInformation()
.andExpect(status().isCreated)
}
@Test
@DisplayName("Deve retornar a tarefa criada como JSON")
fun shouldReturnCreatedTaskAsJson() {
createTaskWithValidInformation()
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
}
@Test
@DisplayName("Deve retornar tarefa criada sem responsável e sem finalizador")
fun shouldReturnCreatedTaskWithoutAssigneeAndCloser() {
createTaskWithValidInformation()
.andExpect(jsonPath("$.assignee", nullValue()))
.andExpect(jsonPath("$.closer", nullValue()))
}
@Test
@DisplayName("Deve retornar as informações de auditoria de criação")
fun shouldReturnCreationInformation() {
createTaskWithValidInformation()
.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 criada")
fun shouldReturnIdTitleAndDescription() {
createTaskWithValidInformation()
.andExpect(jsonPath("$.id", equalTo(createdTaskId.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() {
createTaskWithValidInformation()
.andExpect(jsonPath("$.modificationTime", equalTo(modificationTimeString)))
.andExpect(jsonPath("$.modifier.userId", equalTo(modifierId.toInt())))
.andExpect(jsonPath("$.modifier.name", equalTo(modifierName)))
}
@Test
@DisplayName("Deve retornar uma tarefa aberta que ainda não foi resolvida")
fun shouldReturnOpenTaskThatHasNotBeenResolved() {
createTaskWithValidInformation()
.andExpect(jsonPath("$.resolution", nullValue()))
.andExpect(jsonPath("$.status", equalTo(TaskStatus.OPEN.name)))
}
@Test
@DisplayName("Deve retornar uma tarefa que não possui tags")
fun shouldReturnTaskThatHasNoTags() {
createTaskWithValidInformation()
.andExpect(jsonPath("$.tags", hasSize<Any>(0)))
}
@Test
@DisplayName("Deve criar uma tarefa sem ID pré-definido")
fun shouldCreateTaskWithoutId() {
createTaskWithValidInformation()
verify(exactly = 1) {
crudService.create(withArg { task ->
assertThat(task.id).isNull()
})
}
}
@Test
@DisplayName("Deve criar uma tarefa com o título correto")
fun shouldCreateTaskWithCorrectTitle() {
createTaskWithValidInformation()
verify(exactly = 1) {
crudService.create(withArg { task ->
assertThat(task.title).isEqualTo(title)
})
}
}
@Test
@DisplayName("Deve criar uma tarefa com a descrição correta")
fun shouldCreateTaskWithCorrectDescription() {
createTaskWithValidInformation()
verify(exactly = 1) {
crudService.create(withArg { task ->
assertThat(task.description).isEqualTo(description)
})
}
}
}
}
|
Resumo
Esta aula nos ensinou quatro conceitos fundamentais:
- Podemos converter objetos em um array de bytes JSON (
ByteArray) usando um método utilitário e o nosso ObjectMapper customizado (objectMapper.writeValueAsBytes(model)).
- Podemos definir o tipo de conteúdo de uma requisição HTTP usando o método
contentType(MediaType.APPLICATION_JSON) da classe MockHttpServletRequestBuilder.
- Podemos enviar documentos JSON para o método do controlador sob teste usando o método
content(...) da classe MockHttpServletRequestBuilder.
- Ao escrever asserções para múltiplos erros de validação retornados ao cliente, devemos sempre lembrar que eles são retornados em ordem arbitrária e usar
jsonPath("$.fieldErrors[*].field", containsInAnyOrder(...)).