Depois de concluirmos esta aula, nós:
- Saberemos como escrever testes de integração com o Kotest para métodos controladores que processam submissões de formulários.
- 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 renderizam dados.
Introdução ao Sistema sob Especificação
Temos que especificar o comportamento esperado do método controlador que processa requisições POST enviadas ao caminho /task/create. O nome deste método é processCreateTaskForm(), e ele é implementado seguindo estes passos:
- Se o formulário submetido possuir erros de validação, retornar o nome da view de formulário (
task/create).
- Salvar a tarefa criada no banco de dados invocando o método
create() da interface TaskCrudService.
- Criar uma mensagem de feedback informando que uma nova tarefa foi criada e garantir que essa mensagem seja exibida quando a próxima view for renderizada (
FlashAttribute).
- Redirecionar a requisição para a view que renderiza as informações da tarefa criada (
redirect:/task/{taskId}).
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
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
|
package com.testwithspring.master.web
import com.testwithspring.master.task.TaskCrudService
import com.testwithspring.master.task.TaskFormDTO
import com.testwithspring.master.user.LoggedInUser
import jakarta.validation.Valid
import org.springframework.context.MessageSource
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.stereotype.Controller
import org.springframework.validation.BindingResult
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import java.util.Locale
@Controller
class TaskCrudController(
private val crudService: TaskCrudService,
private val messageSource: MessageSource
) {
@PostMapping("/task/create")
fun processCreateTaskForm(
@Valid @ModelAttribute("task") formObject: TaskFormDTO,
bindingResult: BindingResult,
redirectAttributes: RedirectAttributes,
currentLocale: Locale,
@AuthenticationPrincipal loggedInUser: LoggedInUser
): String {
if (bindingResult.hasErrors()) {
return "task/create"
}
val created = crudService.create(formObject, loggedInUser)
addFeedbackMessage(
redirectAttributes,
"feedback.message.task.created",
currentLocale,
created.title
)
redirectAttributes.addAttribute("taskId", created.id)
return "redirect:/task/{taskId}"
}
private fun addFeedbackMessage(
attributes: RedirectAttributes,
messageCode: String,
currentLocale: Locale,
vararg messageParameters: Any
) {
val localizedFeedbackMessage = messageSource.getMessage(
messageCode,
messageParameters,
currentLocale
)
attributes.addFlashAttribute("feedbackMessage", localizedFeedbackMessage)
}
}
|
A classe TaskFormDTO contém as informações do objeto de formulário usado para criar novas tarefas e atualizar tarefas existentes, além de declarar as regras do Jakarta Bean Validation:
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 = ""
)
|
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. Toda submissão POST exige um token CSRF válido e autenticação.
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 (ProcessInvalidCreateTaskFormSpec) 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. Anotamos nossa classe de especificação com @Sql executando no-tasks-and-tags.sql e users.sql, garantindo que os usuários existam e que a tabela tasks comece vazia (zero linhas).
Terceiro, temos que escrever uma função auxiliar privada que submete um formulário de criação de tarefa vazio (com token CSRF). Esta função retorna um objeto ResultActions:
1
2
3
4
5
6
7
|
fun submitEmptyCreateTaskForm(): ResultActions =
mockMvc.perform(
post("/task/create")
.param("description", "")
.param("title", "")
.with(csrf())
)
|
Quarto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário anônimo submete um formulário de criação de tarefa vazio. Podemos escrever este cenário seguindo estes passos:
- Adicionar um novo bloco
Given / When / Then à nossa classe de especificação.
- Submeter um formulário de criação de tarefa vazio e armazenar o objeto
ResultActions retornado na variável response.
- Verificar se o método controlador testado retorna o código de status HTTP 302 (
status().isFound).
- Garantir que o método controlador testado redirecione o usuário para a página de login (
http://localhost/user/login).
- Garantir que o sistema sob especificação não salve uma nova tarefa no banco de dados (
JdbcTestUtils.countRowsInTable(jdbcTemplate, "tasks") shouldBe 0).
Quinto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário registrado submete um formulário de criação de tarefa vazio. Podemos escrever este cenário seguindo estes passos:
- Adicionar um novo bloco
Given / When / Then à nossa classe de especificação e configurar o usuário autenticado (Users.JohnDoe.EMAIL_ADDRESS).
- Submeter um formulário de criação de tarefa vazio e armazenar o objeto
ResultActions retornado na variável response.
- Garantir que o método controlador testado retorne o código de status HTTP 200 (
status().isOk).
- Verificar se o método controlador testado exibe a view de criação de tarefa (
task/create).
- Garantir que o método controlador testado encaminhe o usuário para a página de criação de tarefa (
/WEB-INF/jsp/task/create.jsp).
- Verificar se o método controlador testado exibe um erro de validação sobre um título em branco (
NotBlank).
- Garantir que o método controlador testado exiba um formulário de criação de tarefa vazio.
- Verificar se o método controlador testado não define o
id do objeto de formulário (nullValue()).
- Garantir que o sistema sob especificação não salve uma nova tarefa no banco de dados (
countRowsInTable igual a 0).
Após termos escrito estes cenários, o código-fonte completo de ProcessInvalidCreateTaskFormSpec 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
|
package com.testwithspring.master.web
import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.Users
import com.testwithspring.master.config.Profiles
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.allOf
import org.hamcrest.Matchers.hasProperty
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.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.forwardedUrl
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view
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 ProcessInvalidCreateTaskFormSpec(
private val webAppContext: WebApplicationContext,
private val userDetailsService: UserDetailsService,
private val jdbcTemplate: JdbcTemplate
) : 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 submitEmptyCreateTaskForm(): ResultActions =
mockMvc.perform(
post("/task/create")
.param("description", "")
.param("title", "")
.with(csrf())
)
fun submitEmptyCreateTaskFormAsUser(email: String): ResultActions {
val userDetails = userDetailsService.loadUserByUsername(email)
return mockMvc.perform(
post("/task/create")
.param("description", "")
.param("title", "")
.with(csrf())
.with(user(userDetails))
)
}
Given("um usuário anônimo") {
When("o usuário anônimo submete um formulário de criação de tarefa vazio") {
val response = submitEmptyCreateTaskForm()
Then("deve retornar o código de status HTTP Found (302)") {
response.andExpect(status().isFound)
}
Then("deve redirecionar o usuário para a página de login") {
response.andExpect(redirectedUrl("http://localhost/user/login"))
}
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 submete um formulário de criação de tarefa vazio") {
val response = submitEmptyCreateTaskFormAsUser(Users.JohnDoe.EMAIL_ADDRESS)
Then("deve retornar o código de status HTTP OK (200)") {
response.andExpect(status().isOk)
}
Then("deve renderizar a view de criação de tarefa") {
response.andExpect(view().name("task/create"))
}
Then("deve encaminhar o usuário para a página de criação de tarefa") {
response.andExpect(forwardedUrl("/WEB-INF/jsp/task/create.jsp"))
}
Then("deve exibir um erro de validação sobre o título vazio") {
response.andExpect(
model().attributeHasFieldErrorCode(
"task",
"title",
`is`("NotBlank")
)
)
}
Then("deve exibir um formulário de criação de tarefa vazio") {
response.andExpect(
model().attribute(
"task",
allOf(
hasProperty("description", `is`("")),
hasProperty("title", `is`(""))
)
)
)
}
Then("não deve modificar o parâmetro oculto id") {
response.andExpect(
model().attribute(
"task",
allOf(
hasProperty("id", nullValue())
)
)
)
}
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 (ProcessValidCreateTaskFormSpec) 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.
Nota: Se você não lembra o que é o bean IdColumnReset, consulte a aula: Resetando Valores de Colunas de ID Geradas Antes de Cada Método de Teste.
Quarto, temos que criar uma função privada que submete o formulário de criação de 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 submete o formulário de criação de tarefa com informações válidas:
- Submeter o formulário de criação de tarefa e armazenar o
ResultActions na variável response.
- Verificar se o método controlador testado retorna o código de status HTTP 302.
- Garantir que o método controlador testado redirecione o usuário para a página de login.
- 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 submete o formulário de criação de tarefa usando informações válidas:
- Submeter o formulário autenticado como
Users.JohnDoe.EMAIL_ADDRESS e armazenar o ResultActions na variável response.
- Garantir que o método controlador testado retorne o código de status HTTP 302 (
status().isFound).
- Verificar se o método controlador testado redireciona o usuário para a view de visualização de tarefa (
redirect:/task/{taskId} com atributo taskId = "1").
- Garantir que o método controlador testado redirecione o usuário para a URL
/task/1.
- Verificar se o método controlador testado exibe a mensagem de feedback correta para o usuário (
A new task was created successfully.).
- Garantir que o sistema sob especificação salve a nova tarefa no banco de dados com todas as colunas esperadas.
Após termos escrito este cenário, o código-fonte completo de ProcessValidCreateTaskFormSpec 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
|
package com.testwithspring.master.web
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.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.`is`
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
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.flash
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.model
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view
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 ProcessValidCreateTaskFormSpec(
private val idColumnReset: IdColumnReset,
private val webAppContext: WebApplicationContext,
private val userDetailsService: UserDetailsService,
private val jdbcTemplate: JdbcTemplate
) : 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 submitCreateTaskForm(): ResultActions =
mockMvc.perform(
post("/task/create")
.param("description", Tasks.WriteExampleApp.DESCRIPTION)
.param("title", Tasks.WriteExampleApp.TITLE)
.with(csrf())
)
fun submitCreateTaskFormAsUser(email: String): ResultActions {
val userDetails = userDetailsService.loadUserByUsername(email)
return mockMvc.perform(
post("/task/create")
.param("description", Tasks.WriteExampleApp.DESCRIPTION)
.param("title", Tasks.WriteExampleApp.TITLE)
.with(csrf())
.with(user(userDetails))
)
}
Given("um usuário anônimo") {
When("o usuário anônimo submete o formulário de criação de tarefa") {
val response = submitCreateTaskForm()
Then("deve retornar o código de status HTTP Found (302)") {
response.andExpect(status().isFound)
}
Then("deve redirecionar o usuário para a página de login") {
response.andExpect(redirectedUrl("http://localhost/user/login"))
}
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 submete o formulário de criação de tarefa válido") {
val response = submitCreateTaskFormAsUser(Users.JohnDoe.EMAIL_ADDRESS)
Then("deve retornar o código de status HTTP Found (302)") {
response.andExpect(status().isFound)
}
Then("deve redirecionar o usuário para a view de visualização da tarefa") {
response.andExpect(view().name("redirect:/task/{taskId}"))
.andExpect(model().attribute("taskId", `is`("1")))
}
Then("deve redirecionar o usuário para a página de visualização da tarefa") {
response.andExpect(redirectedUrl("/task/1"))
}
Then("deve exibir a mensagem de feedback para o usuário") {
response.andExpect(
flash().attribute(
"feedbackMessage",
"A new task was created successfully."
)
)
}
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 métodos controladores que processam submissões de formulários.
Estruturando Nossas Classes de Especificação
Ao criarmos nossas classes de especificação para submissão de formulários, podemos 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 (
ProcessInvalidCreateTaskFormSpec).
- 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 (
ProcessValidCreateTaskFormSpec).
- 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 métodos controladores que processam submissões de formulários. Vamos resumir o que aprendemos nesta aula.
Resumo
Esta aula nos ensinou três coisas:
- Devemos criar uma classe de especificação dedicada que especifique o comportamento do sistema sob especificação quando a validação falha.
- Devemos criar uma classe de especificação dedicada 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 o estado final da tabela no banco de dados após a requisição HTTP.