Depois de concluirmos esta aula, nós:
- Saberemos escrever testes de integração com o Kotest para uma API REST que grava informações no banco de dados.
- Compreenderemos como devemos estruturar nossas classes de especificação quando especificamos o comportamento do sistema sob especificação.
Esta aula assume que:
- Você sabe gerenciar o estado do banco de dados com scripts SQL e Testcontainers.
- Você sabe configurar seus testes de integração que utilizam JUnit 6.
- Você sabe escrever testes de integração para aplicações web Spring utilizando JUnit 6.
- Você está familiarizado com o Kotest Framework.
- Você sabe configurar o sistema sob especificação ao escrever testes de integração com o Kotest.
- Você sabe usar
@Sql e JdbcTemplate com o Kotest Framework.
- Você sabe escrever testes de integração para métodos controladores que processam submissões de formulários.
- Você sabe escrever testes de integração para uma API REST que lê informações do banco de dados.
Introdução ao Sistema sob Especificação
Temos que especificar o comportamento do método controlador que processa requisições POST enviadas ao caminho /api/task. O nome deste método é create(), e ele é implementado seguindo estes passos:
- Ler as informações da tarefa criada a partir do corpo da requisição (
@RequestBody) e validar as informações (@Valid).
- Se erros de validação forem encontrados, retornar o código de status HTTP 400 (
Bad Request) e descrever os erros de validação no corpo da resposta.
- Criar uma nova tarefa e retornar as informações da tarefa criada. Lembre-se de retornar o código de status HTTP 201 (
Created).
O código-fonte do sistema sob especificação 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
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)
}
}
|
A classe TaskFormDTO contém as informações usadas para criar novas tarefas e atualizar tarefas existentes, bem como as regras de validação:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
package com.testwithspring.master.task
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class TaskFormDTO(
var id: Long? = null,
@field:Size(max = 500)
var description: String = "",
@field:NotBlank
@field:Size(max = 100)
var title: String = ""
)
|
Os erros de validação são processados pela classe TaskErrorHandler (@RestControllerAdvice). Se o cliente tentar criar uma nova tarefa sem título, o seguinte documento JSON será retornado ao cliente:
1
2
3
4
5
6
7
8
|
{
"fieldErrors": [
{
"field": "title",
"errorCode": "NotBlank"
}
]
}
|
Após o método create() ter criado uma nova tarefa, ele retorna as informações da tarefa criada de volta para o cliente. Por exemplo, o documento JSON retornado pode ser assim:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
{
"creationTime": "2017-07-30T11:41:28+03:00[Europe/Helsinki]",
"creator": {
"name": "John Doe",
"userId": 99
},
"description": "description",
"id": 1,
"modificationTime": "2017-07-31T11:41:28+03:00[Europe/Helsinki]",
"modifier": {
"name": "John Doe",
"userId": 99
},
"status": "OPEN",
"tags": [],
"title": "title"
}
|
Nota: Os requisitos de segurança descritos na aula Escrevendo Testes de Segurança para Controladores Spring MVC também são válidos para esta aula.
A seguir, descobriremos como podemos especificar o comportamento esperado do sistema sob especificação quando a validação falha.
Especificando o Comportamento Esperado
Cenário 1: A Validação Falha
Podemos especificar o comportamento do sistema sob especificação seguindo estes passos:
Primeiro, temos que criar uma nova classe de especificação (CreateInvalidTaskSpec) e configurar o sistema sob especificação e o MockMvc com springSecurity().
Segundo, temos que inicializar nosso banco de dados em um estado conhecido antes que nossos cenários sejam executados (no-tasks-and-tags.sql e users.sql), garantindo que a tabela tasks comece vazia (zero linhas).
Terceiro, temos que escrever uma função auxiliar privada que tenta criar uma tarefa com título e descrição vazios via POST /api/task com JSON e token CSRF:
1
2
3
4
5
6
7
8
9
|
fun createEmptyTask(): ResultActions {
val input = TaskFormDTO(title = "", description = "")
return mockMvc.perform(
post("/api/task")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(input))
.with(csrf())
)
}
|
Quarto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário anônimo tenta criar uma tarefa com título e descrição vazios:
- Criar uma nova tarefa vazia e armazenar o objeto
ResultActions na variável response.
- Verificar se o método controlador testado retorna o código de status HTTP 401 (
status().isUnauthorized).
- Garantir que o método controlador testado retorne uma resposta vazia (
content().string("")).
- Garantir que o sistema sob especificação não salve uma nova tarefa no banco de dados (
countRowsInTable igual a 0).
Quinto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário registrado tenta criar uma tarefa com título e descrição vazios:
- Criar uma nova tarefa vazia autenticado como
Users.JohnDoe.EMAIL_ADDRESS e armazenar o objeto ResultActions na variável response.
- Garantir que o método controlador testado retorne o código de status HTTP 400 (
status().isBadRequest).
- Verificar se o método controlador testado retorna os erros de validação como JSON.
- Garantir que o método controlador testado retorne um erro de validação (
jsonPath("$.fieldErrors", hasSize(1))).
- Verificar se o método controlador testado retorna um erro de validação sobre um título em branco (
field = "title", errorCode = "NotBlank").
- Garantir que o sistema sob especificação não salve uma nova tarefa no banco de dados.
Após termos escrito este cenário, o código-fonte completo de CreateInvalidTaskSpec fica assim:
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
|
package com.testwithspring.master.web
import com.fasterxml.jackson.databind.ObjectMapper
import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.Users
import com.testwithspring.master.config.Profiles
import com.testwithspring.master.task.TaskFormDTO
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import io.kotest.matchers.shouldBe
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.jdbc.JdbcTestUtils
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
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
import org.springframework.web.context.WebApplicationContext
@SpringBootTest
@Import(PostgresTestcontainersConfiguration::class)
@Sql(
value = [
"classpath:/com/testwithspring/master/no-tasks-and-tags.sql",
"classpath:/com/testwithspring/master/users.sql"
],
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class CreateInvalidTaskSpec(
private val webAppContext: WebApplicationContext,
private val userDetailsService: UserDetailsService,
private val jdbcTemplate: JdbcTemplate,
private val objectMapper: ObjectMapper
) : BehaviorSpec({
extensions(SpringExtension(SpringTestLifecycleMode.Root))
lateinit var mockMvc: MockMvc
beforeSpec {
mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
.apply<org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder>(springSecurity())
.build()
}
fun createEmptyTask(): ResultActions {
val input = TaskFormDTO(title = "", description = "")
return mockMvc.perform(
post("/api/task")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(input))
.with(csrf())
)
}
fun createEmptyTaskAsUser(email: String): ResultActions {
val input = TaskFormDTO(title = "", description = "")
val userDetails = userDetailsService.loadUserByUsername(email)
return mockMvc.perform(
post("/api/task")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(input))
.with(csrf())
.with(user(userDetails))
)
}
Given("um usuário anônimo") {
When("o usuário anônimo tenta criar uma tarefa vazia") {
val response = createEmptyTask()
Then("deve retornar o código de status HTTP Unauthorized (401)") {
response.andExpect(status().isUnauthorized)
}
Then("deve retornar uma resposta vazia") {
response.andExpect(content().string(""))
}
Then("não deve salvar nenhuma tarefa no banco de dados") {
JdbcTestUtils.countRowsInTable(jdbcTemplate, "tasks") shouldBe 0
}
}
}
Given("um usuário registrado autenticado") {
When("o usuário registrado tenta criar uma tarefa vazia") {
val response = createEmptyTaskAsUser(Users.JohnDoe.EMAIL_ADDRESS)
Then("deve retornar o código de status HTTP Bad Request (400)") {
response.andExpect(status().isBadRequest)
}
Then("deve retornar os erros de validação como JSON") {
response.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
}
Then("deve retornar um erro de validação") {
response.andExpect(jsonPath("$.fieldErrors", hasSize(1)))
}
Then("deve retornar um erro de validação sobre o título vazio") {
response
.andExpect(jsonPath("$.fieldErrors[0].field", `is`("title")))
.andExpect(jsonPath("$.fieldErrors[0].errorCode", `is`("NotBlank")))
}
Then("não deve salvar nenhuma tarefa no banco de dados") {
JdbcTestUtils.countRowsInTable(jdbcTemplate, "tasks") shouldBe 0
}
}
}
})
|
Vamos avançar e descobrir como podemos especificar o comportamento esperado do sistema sob especificação quando a validação é bem-sucedida.
Cenário 2: A Validação é Bem-Sucedida
Podemos especificar o comportamento do sistema sob especificação seguindo estes passos:
Primeiro, temos que criar uma nova classe de especificação (CreateValidTaskSpec) e configurar o sistema sob especificação e o MockMvc.
Segundo, temos que inicializar nosso banco de dados em um estado conhecido antes que nossos cenários sejam executados (no-tasks-and-tags.sql e users.sql).
Terceiro, temos que injetar o bean IdColumnReset na nossa classe de especificação e garantir que o id da tarefa criada seja sempre 1.
Quarto, temos que escrever uma função privada que cria uma nova tarefa usando informações válidas e retorna um objeto ResultActions.
Quinto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário anônimo tenta criar uma nova tarefa usando informações válidas:
- Criar uma nova tarefa e armazenar o
ResultActions na variável response.
- Verificar se o método controlador testado retorna o código de status HTTP 401 (
status().isUnauthorized).
- Garantir que o método controlador testado retorne uma resposta vazia.
- Garantir que o sistema sob especificação não salve uma nova tarefa no banco de dados.
Sexto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário registrado cria uma nova tarefa usando informações válidas:
- Criar uma nova tarefa autenticado como
Users.JohnDoe.EMAIL_ADDRESS e armazenar o ResultActions na variável response.
- Verificar se o método controlador testado retorna o código de status HTTP 201 (
status().isCreated).
- Garantir que o método controlador testado retorne a tarefa criada como JSON.
- Verificar se o método controlador testado retorna as informações da tarefa criada no corpo JSON.
- Garantir que o método controlador testado retorne uma tarefa que não possui tags (
jsonPath("$.tags", hasSize(0))).
- Garantir que o sistema sob especificação salve a nova tarefa na tabela
tasks do banco de dados.
Após termos escrito este cenário, o código-fonte completo de CreateValidTaskSpec fica assim:
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
171
172
|
package com.testwithspring.master.web
import com.fasterxml.jackson.databind.ObjectMapper
import com.testwithspring.master.ConstantDateTimeService
import com.testwithspring.master.IdColumnReset
import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.Tasks
import com.testwithspring.master.Users
import com.testwithspring.master.config.Profiles
import com.testwithspring.master.task.TaskFormDTO
import com.testwithspring.master.task.TaskStatus
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.http.MediaType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.jdbc.JdbcTestUtils
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
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
import org.springframework.web.context.WebApplicationContext
@SpringBootTest
@Import(PostgresTestcontainersConfiguration::class)
@Sql(
value = [
"classpath:/com/testwithspring/master/no-tasks-and-tags.sql",
"classpath:/com/testwithspring/master/users.sql"
],
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class CreateValidTaskSpec(
private val webAppContext: WebApplicationContext,
private val idColumnReset: IdColumnReset,
private val userDetailsService: UserDetailsService,
private val jdbcTemplate: JdbcTemplate,
private val objectMapper: ObjectMapper
) : BehaviorSpec({
extensions(SpringExtension(SpringTestLifecycleMode.Root))
lateinit var mockMvc: MockMvc
beforeSpec {
mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
.apply<org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder>(springSecurity())
.build()
}
beforeContainer {
if (it.descriptor.isRootTest()) {
idColumnReset.resetIdColumns("tasks")
}
}
fun createTask(): ResultActions {
val input = TaskFormDTO(
title = Tasks.WriteExampleApp.TITLE,
description = Tasks.WriteExampleApp.DESCRIPTION
)
return mockMvc.perform(
post("/api/task")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(input))
.with(csrf())
)
}
fun createTaskAsUser(email: String): ResultActions {
val input = TaskFormDTO(
title = Tasks.WriteExampleApp.TITLE,
description = Tasks.WriteExampleApp.DESCRIPTION
)
val userDetails = userDetailsService.loadUserByUsername(email)
return mockMvc.perform(
post("/api/task")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(input))
.with(csrf())
.with(user(userDetails))
)
}
Given("um usuário anônimo") {
When("o usuário anônimo tenta criar uma tarefa") {
val response = createTask()
Then("deve retornar o código de status HTTP Unauthorized (401)") {
response.andExpect(status().isUnauthorized)
}
Then("deve retornar uma resposta vazia") {
response.andExpect(content().string(""))
}
Then("não deve salvar nenhuma tarefa no banco de dados") {
JdbcTestUtils.countRowsInTable(jdbcTemplate, "tasks") shouldBe 0
}
}
}
Given("um usuário registrado autenticado") {
When("o usuário registrado cria uma tarefa usando informações válidas") {
val response = createTaskAsUser(Users.JohnDoe.EMAIL_ADDRESS)
Then("deve retornar o código de status HTTP Created (201)") {
response.andExpect(status().isCreated)
}
Then("deve retornar a tarefa criada como JSON") {
response.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
}
Then("deve retornar as informações da tarefa criada") {
response
.andExpect(jsonPath("$.assignee", nullValue()))
.andExpect(jsonPath("$.closer", nullValue()))
.andExpect(jsonPath("$.creationTime", `is`(ConstantDateTimeService.CURRENT_DATE_AND_TIME)))
.andExpect(jsonPath("$.creator.userId", `is`(Users.JohnDoe.ID.toInt())))
.andExpect(jsonPath("$.creator.name", `is`(Users.JohnDoe.NAME)))
.andExpect(jsonPath("$.id", `is`(1)))
.andExpect(jsonPath("$.modificationTime", `is`(ConstantDateTimeService.CURRENT_DATE_AND_TIME)))
.andExpect(jsonPath("$.modifier.userId", `is`(Users.JohnDoe.ID.toInt())))
.andExpect(jsonPath("$.modifier.name", `is`(Users.JohnDoe.NAME)))
.andExpect(jsonPath("$.title", `is`(Tasks.WriteExampleApp.TITLE)))
.andExpect(jsonPath("$.description", `is`(Tasks.WriteExampleApp.DESCRIPTION)))
.andExpect(jsonPath("$.status", `is`(TaskStatus.OPEN.toString())))
.andExpect(jsonPath("$.resolution", nullValue()))
}
Then("deve retornar uma tarefa que não possui tags") {
response.andExpect(jsonPath("$.tags", hasSize(0)))
}
Then("deve salvar a nova tarefa no banco de dados") {
JdbcTestUtils.countRowsInTable(jdbcTemplate, "tasks") shouldBe 1
val savedRow = jdbcTemplate.queryForMap("SELECT * FROM tasks WHERE id = 1")
assertSoftly(savedRow) {
this["assignee_id"].shouldBeNull()
this["closer_id"].shouldBeNull()
this["creator_id"] shouldBe Users.JohnDoe.ID
this["description"] shouldBe Tasks.WriteExampleApp.DESCRIPTION
this["modifier_id"] shouldBe Users.JohnDoe.ID
this["resolution"].shouldBeNull()
this["title"] shouldBe Tasks.WriteExampleApp.TITLE
this["status"] shouldBe TaskStatus.OPEN.name
}
}
}
}
})
|
A seguir, descobriremos como podemos estruturar nossas classes de especificação quando estamos escrevendo testes de integração para uma API REST que grava informações no banco de dados.
Estruturando Nossas Classes de Especificação
Ao criarmos nossas classes de especificação para endpoints REST de escrita, devemos estruturá-las seguindo estas regras:
- Devemos criar uma classe de especificação que contenha os cenários que especificam o comportamento do sistema sob especificação quando a validação falha (
CreateInvalidTaskSpec).
- Devemos criar uma classe de especificação que contenha os cenários que especificam o comportamento do sistema sob especificação quando a validação é bem-sucedida (
CreateValidTaskSpec).
- Devemos escrever um cenário por papel de usuário (user role). Isso nos ajuda a minimizar o número de classes de especificação e a escrever testes de integração que possuem uma estrutura limpa e lógica.
Agora sabemos escrever testes de integração para uma API REST que grava informações no banco de dados. Vamos resumir o que aprendemos nesta aula.
Resumo
Esta aula nos ensinou três coisas:
- Devemos criar uma classe de especificação que especifique o comportamento do sistema sob especificação quando a validação falha.
- Devemos criar uma classe de especificação que especifique o comportamento do sistema sob especificação quando a validação é bem-sucedida.
- Devemos escrever um cenário por papel de usuário e verificar tanto o payload JSON retornado quanto o estado persistido no banco de dados.