Ao concluir esta lição, você será capaz de:
- Compreender que tipo de massa de dados devemos usar ao escrever testes de integração para endpoints de
@RestController que gravam dados no banco de dados.
- Escrever asserções JSON resilientes para múltiplos erros de validação (independentemente da ordem em que o Bean Validation os retorne).
- Escrever testes de integração completos para endpoints REST que atualizam ou inserem dados no banco de dados, validando tanto o payload JSON de resposta quanto o estado persistido nas tabelas.
Esta lição assume que:
1. Introdução ao Sistema Sob Teste (SUT)
Precisamos escrever testes de integração para um método de @RestController que processa requisições HTTP PUT enviadas para o caminho /api/task/{taskId}. Esse método se chama update(), e é implementado seguindo estes quatro passos:
- Ler as informações da tarefa atualizada a partir do corpo da requisição JSON (
@RequestBody) e validar suas restrições (@Valid).
- Se forem encontrados erros de validação, retornar o código de status HTTP
400 Bad Request e descrever os erros de campo no corpo da resposta JSON (via @RestControllerAdvice / TaskErrorHandler).
- Definir o
id da tarefa a ser atualizada com base na variável de caminho taskId.
- Atualizar as informações da tarefa no banco de dados e retornar o
TaskDTO atualizado como JSON.
O código-fonte do método testado em Kotlin é o seguinte:
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
|
package com.testwithspring.intermediate.web
import com.testwithspring.intermediate.task.TaskCrudService
import com.testwithspring.intermediate.task.TaskDTO
import com.testwithspring.intermediate.task.TaskFormDTO
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/task")
class TaskCrudController(
private val crudService: TaskCrudService
) {
@PutMapping("/{taskId}")
fun update(
@PathVariable("taskId") taskId: Long,
@Valid @RequestBody task: TaskFormDTO
): TaskDTO {
task.id = taskId
return crudService.update(task)
}
}
|
A classe TaskFormDTO contém os dados de entrada e as regras de validação do Jakarta Bean Validation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
package com.testwithspring.intermediate.task
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
class TaskFormDTO(
var id: Long? = null,
@field:Size(max = 500)
var description: String? = null,
@field:NotBlank
@field:Size(max = 100)
var title: String? = null
)
|
Os erros de validação são processados pela classe TaskErrorHandler (@ControllerAdvice). Se o cliente tentar atualizar uma tarefa enviando um título em branco (""), o seguinte documento JSON será retornado com status 400 Bad Request:
1
2
3
4
5
6
7
8
|
{
"fieldErrors": [
{
"field": "title",
"errorCode": "NotBlank"
}
]
}
|
Por outro lado, se as informações de uma tarefa aberta forem atualizadas com sucesso, o seguinte documento JSON será retornado ao cliente com status 200 OK:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
{
"assigneeId": null,
"closerId": null,
"creationTime": "2016-12-04T11:41:28+02:00[Europe/Helsinki]",
"creatorId": 1,
"description": "The old lesson was not good",
"id": 2,
"modificationTime": "2016-12-03T21:14:28+02:00[Europe/Helsinki]",
"resolution": null,
"status": "OPEN",
"tags": [
{
"id": 2,
"name": "lesson"
}
],
"title": "Rewrite an existing lesson"
}
|
Vamos criar o dataset do DbUnit que inicializa nosso banco de dados em um estado conhecido antes dos testes.
2. Inicializando o Banco de Dados em um Estado Conhecido
Ao escrever testes de integração para endpoints REST que modificam o banco de dados, seguimos exatamente os mesmos princípios estabelecidos na lição de formulários web:
Como estamos testando um método que atualiza uma tarefa existente, criamos um dataset chamado task.xml que insere uma única linha nas tabelas tasks, tags e tasks_tags:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
<dataset>
<tasks id="2"
assignee_id="[null]"
closer_id="[null]"
creation_time="2016-12-4 11:41:28"
creator_id="1"
description="This lesson talks about integration testing"
modification_time="2016-12-4 11:41:28"
resolution="[null]"
status="OPEN"
title="Write lesson"
version="0"/>
<tags id="2"
creation_time="2016-12-3 11:41:28"
modification_time="2016-12-3 11:41:28"
name="lesson"
version="0"/>
<tasks_tags task_id="2" tag_id="2"/>
</dataset>
|
A seguir, escreveremos os testes de integração para os cenários de falha de validação e de sucesso.
3. Escrevendo os Testes de Integração
Cenário 1: A Validação Falha
Configuramos nossa classe de teste UpdateTaskWhenValidationFailsTest seguindo estes dois passos:
- Configurar o dataset
@DatabaseSetup("task.xml") na classe.
- Criar um método privado
updateTaskWithEmptyData() que instancia um TaskFormDTO com title = "" e description = "", converte-o para bytes JSON usando WebTestUtil.convertObjectToJsonBytes(input) e envia uma requisição PUT para /api/task/2.
Em seguida, escrevemos cinco testes de integração:
- Primeiro, verificar que retorna o código de status HTTP
400 Bad Request (status().isBadRequest).
- Segundo, garantir que retorna os erros de validação como JSON (
content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)).
- Terceiro, verificar que retorna exatamente um erro de validação (
jsonPath("$.fieldErrors", hasSize<Any>(1))).
- Quarto, garantir que retorna o erro de validação sobre o campo
title vazio (field = "title", errorCode = "NotBlank").
- Quinto, verificar que não atualiza a tarefa no banco de dados (
@ExpectedDatabase(value = "task.xml", assertionMode = DatabaseAssertionMode.NON_STRICT)).
O código completo da classe UpdateTaskWhenValidationFailsTest é o seguinte:
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
|
package com.testwithspring.intermediate.web
import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.github.springtestdbunit.annotation.ExpectedDatabase
import com.github.springtestdbunit.assertion.DatabaseAssertionMode
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.config.Profiles
import com.testwithspring.intermediate.task.TaskFormDTO
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.hasSize
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
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
import org.springframework.web.context.WebApplicationContext
@SpringBootTest
@AutoConfigureMockMvc
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
listeners = [
DependencyInjectionTestExecutionListener::class,
TransactionalTestExecutionListener::class,
DbUnitTestExecutionListener::class,
ServletTestExecutionListener::class
]
)
@DatabaseSetup("task.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class UpdateTaskWhenValidationFailsTest {
@Autowired
private lateinit var webAppContext: WebApplicationContext
private lateinit var mockMvc: MockMvc
@BeforeEach
fun configureSystemUnderTest() {
mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
.build()
}
@Test
fun `deve retornar status HTTP 400 Bad Request`() {
updateTaskWithEmptyData()
.andExpect(status().isBadRequest)
}
@Test
fun `deve retornar erros de validacao como JSON`() {
updateTaskWithEmptyData()
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
}
@Test
fun `deve retornar um erro de validacao`() {
updateTaskWithEmptyData()
.andExpect(jsonPath("$.fieldErrors", hasSize<Any>(1)))
}
@Test
fun `deve retornar erro de validacao sobre titulo vazio`() {
updateTaskWithEmptyData()
.andExpect(jsonPath("$.fieldErrors[0].field", equalTo("title")))
.andExpect(jsonPath("$.fieldErrors[0].errorCode", equalTo("NotBlank")))
}
@Test
@ExpectedDatabase(
value = "task.xml",
assertionMode = DatabaseAssertionMode.NON_STRICT
)
fun `nao deve atualizar a tarefa no banco de dados`() {
updateTaskWithEmptyData()
}
private fun updateTaskWithEmptyData(): ResultActions {
val input = TaskFormDTO(
id = Tasks.WriteLesson.ID,
title = "",
description = ""
)
return mockMvc.perform(
put("/api/task/{taskId}", Tasks.WriteLesson.ID)
.contentType(MediaType.APPLICATION_JSON)
.content(WebTestUtil.convertObjectToJsonBytes(input))
)
}
}
|
Atenção ao Testar Múltiplos Erros de Validação:
Se você precisar escrever um teste de integração que verifique o retorno de múltiplos erros de validação simultâneos (por exemplo, quando tanto title quanto description excedem o tamanho máximo), você não pode usar índices fixos como $.fieldErrors[0], pois o Jakarta Bean Validation não garante a ordem em que as violações são retornadas. Em vez disso, utilize filtros de predicado do JsonPath:
1
2
3
|
updateTaskWithTooLongStrings()
.andExpect(jsonPath("$.fieldErrors[?(@.field == 'title')].errorCode", contains("Size")))
.andExpect(jsonPath("$.fieldErrors[?(@.field == 'description')].errorCode", contains("Size")))
|
Cenário 2: A Validação é Bem-Sucedida
Para testar a atualização bem-sucedida, configuramos nossa classe UpdateTaskWhenValidationIsSuccessfulTest seguindo estes três passos:
- Configurar o dataset
@DatabaseSetup("task.xml") na classe.
- Declarar as constantes
NEW_DESCRIPTION e NEW_TITLE.
- Criar o método auxiliar privado
updateTask() que envia uma requisição PUT para /api/task/2 com o JSON contendo o novo título e a nova descrição.
Lembrete sobre Inserção (POST): Se estivermos escrevendo testes de integração para um endpoint REST que insere novos registros no banco de dados (POST /api/task), devemos resetar os valores gerados da coluna id no método @BeforeEach antes de cada teste!
Em seguida, escrevemos dez testes de integração (5 validando a resposta HTTP/JSON retornada ao cliente e 5 validando o estado persistido no banco de dados com datasets focados):
- Primeiro, verificar que retorna o código de status HTTP
200 OK.
- Segundo, garantir que retorna a tarefa atualizada como JSON (
MediaType.APPLICATION_JSON).
- Terceiro, verificar que retorna no JSON todas as propriedades escalares atualizadas da tarefa (incluindo
modificationTime igual a ConstantDateTimeService.CURRENT_DATE_AND_TIME, title = NEW_TITLE e description = NEW_DESCRIPTION).
- Quarto, garantir que o JSON retornado possui exatamente uma tag (
$.tags com hasSize(1)).
- Quinto, verificar que o JSON retornado contém os dados da tag correta (
Tasks.WriteLesson.Tags.Lesson).
- Sexto, garantir que atualizou o
title e a description no banco de dados (update-task-should-update-title-and-description.xml).
- Sétimo, verificar que atualizou
modification_time e version no banco de dados (update-task-should-update-lifecycle-fields.xml).
- Oitavo, garantir que não alterou o
status, closer_id ou resolution no banco (update-task-should-not-change-status.xml).
- Nono, verificar que não alterou o
assignee_id no banco (update-task-should-not-change-assignee.xml).
- Décimo, garantir que não alterou as tags da tarefa no banco (
update-task-should-not-change-tags.xml).
O código completo da classe UpdateTaskWhenValidationIsSuccessfulTest com todos os 10 testes é o seguinte:
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
|
package com.testwithspring.intermediate.web
import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.github.springtestdbunit.annotation.ExpectedDatabase
import com.github.springtestdbunit.assertion.DatabaseAssertionMode
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.common.ConstantDateTimeService
import com.testwithspring.intermediate.config.Profiles
import com.testwithspring.intermediate.task.TaskFormDTO
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
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
import org.springframework.web.context.WebApplicationContext
@SpringBootTest
@AutoConfigureMockMvc
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
listeners = [
DependencyInjectionTestExecutionListener::class,
TransactionalTestExecutionListener::class,
DbUnitTestExecutionListener::class,
ServletTestExecutionListener::class
]
)
@DatabaseSetup("task.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class UpdateTaskWhenValidationIsSuccessfulTest {
companion object {
private const val NEW_DESCRIPTION = "The old lesson was not good"
private const val NEW_TITLE = "Rewrite an existing lesson"
}
@Autowired
private lateinit var webAppContext: WebApplicationContext
private lateinit var mockMvc: MockMvc
@BeforeEach
fun configureSystemUnderTest() {
mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
.build()
}
@Test
fun `deve retornar status HTTP 200 OK`() {
updateTask()
.andExpect(status().isOk)
}
@Test
fun `deve retornar a tarefa atualizada como JSON`() {
updateTask()
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
}
@Test
fun `deve retornar as informacoes da tarefa atualizada`() {
updateTask()
.andExpect(jsonPath("$.assigneeId", nullValue()))
.andExpect(jsonPath("$.closerId", nullValue()))
.andExpect(jsonPath("$.creationTime", equalTo(Tasks.WriteLesson.CREATION_TIME_STRING)))
.andExpect(jsonPath("$.creatorId", equalTo(Tasks.WriteLesson.CREATOR_ID.toInt())))
.andExpect(jsonPath("$.id", equalTo(Tasks.WriteLesson.ID.toInt())))
.andExpect(jsonPath("$.modificationTime", equalTo(ConstantDateTimeService.CURRENT_DATE_AND_TIME)))
.andExpect(jsonPath("$.title", equalTo(NEW_TITLE)))
.andExpect(jsonPath("$.description", equalTo(NEW_DESCRIPTION)))
.andExpect(jsonPath("$.status", equalTo(Tasks.WriteLesson.STATUS.toString())))
.andExpect(jsonPath("$.resolution", nullValue()))
}
@Test
fun `deve retornar tarefa que possui uma tag`() {
updateTask()
.andExpect(jsonPath("$.tags", hasSize<Any>(1)))
}
@Test
fun `deve retornar as informacoes da tag correta`() {
updateTask()
.andExpect(jsonPath("$.tags[0].id", equalTo(Tasks.WriteLesson.Tags.Lesson.ID.toInt())))
.andExpect(jsonPath("$.tags[0].name", equalTo(Tasks.WriteLesson.Tags.Lesson.NAME)))
}
@Test
@ExpectedDatabase(
value = "update-task-should-update-title-and-description.xml",
assertionMode = DatabaseAssertionMode.NON_STRICT
)
fun `deve atualizar o titulo e a descricao no banco de dados`() {
updateTask()
}
@Test
@ExpectedDatabase(
value = "update-task-should-update-lifecycle-fields.xml",
assertionMode = DatabaseAssertionMode.NON_STRICT
)
fun `deve atualizar modificationTime e version no banco de dados`() {
updateTask()
}
@Test
@ExpectedDatabase(
value = "update-task-should-not-change-status.xml",
assertionMode = DatabaseAssertionMode.NON_STRICT
)
fun `nao deve alterar o status da tarefa no banco de dados`() {
updateTask()
}
@Test
@ExpectedDatabase(
value = "update-task-should-not-change-assignee.xml",
assertionMode = DatabaseAssertionMode.NON_STRICT
)
fun `nao deve alterar o responsavel da tarefa no banco de dados`() {
updateTask()
}
@Test
@ExpectedDatabase(
value = "update-task-should-not-change-tags.xml",
assertionMode = DatabaseAssertionMode.NON_STRICT
)
fun `nao deve fazer nenhuma alteracao nas tags da tarefa no banco de dados`() {
updateTask()
}
private fun updateTask(): ResultActions {
val input = TaskFormDTO(
id = Tasks.WriteLesson.ID,
title = NEW_TITLE,
description = NEW_DESCRIPTION
)
return mockMvc.perform(
put("/api/task/{taskId}", Tasks.WriteLesson.ID)
.contentType(MediaType.APPLICATION_JSON)
.content(WebTestUtil.convertObjectToJsonBytes(input))
)
}
}
|
Os cinco arquivos XML de dataset esperado (update-task-should-*.xml) são exatamente os mesmos datasets enxutos que criamos na lição anterior de formulários web, provando como o design de datasets orientados a conceitos lógicos é 100% reutilizável entre aplicações MVC tradicionais e APIs REST!
Vamos resumir o que aprendemos nesta lição.
Resumo
Esta lição nos ensinou três pontos essenciais:
- Ao escrever testes de integração para um método de controller que insere dados no banco de dados, devemos resetar os valores gerados para a coluna
id antes da execução de cada método de teste.
- Ao escrever asserções que verificam o retorno de múltiplos erros de validação JSON, devemos lembrar que as violações são retornadas em ordem arbitrária e usar predicados JsonPath (
$.fieldErrors[?(@.field == '...')]).
- Ao escrever asserções para os dados persistidos no banco de dados, devemos utilizar datasets pequenos que validem um único conceito lógico por vez.