Depois de concluirmos esta aula, nós:
- Saberemos como escrever testes de integração com o Kotest para controladores Spring MVC tradicionais que renderizam 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 com o Kotest Framework.
Introdução ao Sistema sob Especificação
Temos que especificar o comportamento do método controlador que processa requisições GET enviadas ao caminho /task/{taskId}. O nome deste método é showTask(), e ele é implementado seguindo estes passos:
- Buscar a tarefa no banco de dados invocando
crudService.findById(taskId).
- Colocar a tarefa encontrada em um atributo do model chamado
task.
- Retornar o nome da view (
task/view) que renderiza as informações da tarefa encontrada.
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
|
package com.testwithspring.master.web
import com.testwithspring.master.task.TaskCrudService
import org.springframework.context.MessageSource
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@Controller
class TaskCrudController(
private val crudService: TaskCrudService,
private val messageSource: MessageSource
) {
@GetMapping("/task/{taskId}")
fun showTask(@PathVariable("taskId") taskId: Long, model: Model): String {
val task = crudService.findById(taskId)
model.addAttribute("task", task)
return "task/view"
}
}
|
A classe TaskDTO é um objeto de transferência de dados que contém as informações detalhadas de uma única tarefa:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
package com.testwithspring.master.task
import java.time.ZonedDateTime
data class TaskDTO(
val id: Long,
val title: String,
val description: String,
val status: TaskStatus,
val resolution: TaskResolution? = null,
val creationTime: ZonedDateTime,
val modificationTime: ZonedDateTime,
val creator: PersonDTO,
val modifier: PersonDTO,
val assignee: PersonDTO? = null,
val closer: PersonDTO? = null,
val tags: List<TagDTO> = emptyList()
)
|
A classe PersonDTO contém as informações de uma pessoa:
1
2
3
4
5
6
|
package com.testwithspring.master.task
data class PersonDTO(
val userId: Long,
val name: String
)
|
E a classe TagDTO contém as informações de uma tag:
1
2
3
4
5
6
|
package com.testwithspring.master.task
data class TagDTO(
val id: Long,
val name: 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. Apenas usuários autenticados podem visualizar os detalhes de uma tarefa; usuários anônimos são redirecionados para /user/login.
A seguir, descobriremos como podemos especificar o comportamento esperado do sistema sob especificação.
Especificando o Comportamento Esperado
Podemos especificar o comportamento do sistema sob especificação seguindo estes passos:
Primeiro, temos que criar uma nova classe de especificação e configurar o sistema sob especificação. Além disso, como estamos escrevendo testes de integração para um controlador Spring MVC com segurança habilitada, configuramos o MockMvc com springSecurity().
Após termos configurado o sistema sob especificação, o código-fonte da nossa classe de especificação 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
|
package com.testwithspring.master.web
import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
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 org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
@SpringBootTest
@Import(PostgresTestcontainersConfiguration::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class ShowTaskSpec(
private val webAppContext: WebApplicationContext
) : BehaviorSpec({
extensions(SpringExtension(SpringTestLifecycleMode.Root))
lateinit var mockMvc: MockMvc
beforeSpec {
mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
.apply<org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder>(springSecurity())
.build()
}
})
|
Segundo, temos que inicializar nosso banco de dados em um estado conhecido antes que nossos cenários sejam executados. Como queremos que todos os cenários da nossa classe de especificação utilizem os mesmos scripts de dados, anotamos nossa classe de especificação com @Sql. Quando fazemos isso, especificamos os scripts que limpam e inserem os dados de teste necessários nas tabelas tasks e user_accounts.
Após termos configurado os dados de teste utilizados, o código-fonte da nossa classe de especificação 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
|
package com.testwithspring.master.web
import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
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 org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
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.web.servlet.MockMvc
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",
"classpath:/com/testwithspring/master/tasks.sql"
],
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class ShowTaskSpec(
private val webAppContext: WebApplicationContext
) : BehaviorSpec({
extensions(SpringExtension(SpringTestLifecycleMode.Root))
lateinit var mockMvc: MockMvc
beforeSpec {
mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
.apply<org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder>(springSecurity())
.build()
}
})
|
O script SQL que insere os dados de teste necessários na tabela user_accounts (users.sql) é o seguinte:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
DELETE FROM user_accounts;
INSERT INTO user_accounts (
id, creation_time, email_address, is_enabled, modification_time, name, password, role, version
) VALUES (
1, '2017-02-11 11:41:28', 'john.doe@gmail.com', true, '2017-02-11 11:41:28', 'John Doe', 'user', 'ROLE_USER', 0
);
INSERT INTO user_accounts (
id, creation_time, email_address, is_enabled, modification_time, name, password, role, version
) VALUES (
2, '2017-02-11 11:41:28', 'anne.admin@gmail.com', true, '2017-02-11 11:41:28', 'Anne Admin', 'admin', 'ROLE_ADMIN', 0
);
|
O script SQL que insere os dados de teste necessários na tabela tasks (tasks.sql) é 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
|
INSERT INTO tasks (
id, assignee_id, closer_id, creation_time, creator_id, description,
modification_time, modifier_id, resolution, status, title, version
) VALUES (
1, 1, 1, '2016-12-03 11:41:28', 1, 'This example contains integration tests',
'2016-12-03 11:41:28', 1, 'DONE', 'CLOSED', 'Write example application', 0
);
INSERT INTO tasks (
id, assignee_id, closer_id, creation_time, creator_id, description,
modification_time, modifier_id, resolution, status, title, version
) VALUES (
2, NULL, NULL, '2016-12-04 11:41:28', 1, 'This lesson talks about integration testing',
'2016-12-04 11:41:28', 1, NULL, 'OPEN', 'Write lesson', 0
);
INSERT INTO tags (id, creation_time, modification_time, name, version)
VALUES (1, '2016-12-03 11:41:28', '2016-12-03 11:41:28', 'example', 0);
INSERT INTO tags (id, creation_time, modification_time, name, version)
VALUES (2, '2016-12-03 11:41:28', '2016-12-03 11:41:28', 'lesson', 0);
INSERT INTO tasks_tags (task_id, tag_id) VALUES (1, 1);
INSERT INTO tasks_tags (task_id, tag_id) VALUES (2, 2);
|
Nota: Durante esta aula, utilizaremos dois objetos de constantes:
- O objeto
Users contém as informações dos usuários encontrados no nosso banco de dados.
- O objeto
Tasks contém as informações das tarefas encontradas no nosso banco de dados.
Terceiro, temos que escrever uma função auxiliar privada que recebe o ID da tarefa como parâmetro e envia uma requisição GET ao caminho /task/{taskId}. Esta função retorna um objeto ResultActions e nos ajuda a reduzir o código duplicado encontrado nas classes de especificação que utilizam o Spring MVC Test framework.
Após termos adicionado esta função à nossa classe de especificação, seu código-fonte fica assim:
1
2
|
fun openViewTaskPage(taskId: Long): ResultActions =
mockMvc.perform(get("/task/{taskId}", taskId))
|
Quarto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário anônimo tenta abrir a página de visualização de tarefa. Podemos escrever este cenário seguindo estes passos:
- Adicionar um novo bloco
Given / When / Then à nossa classe de especificação.
- Enviar uma requisição
GET ao caminho /task/1 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).
Após termos escrito este cenário, o código-fonte da nossa classe de especificação fica assim:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
Given("um usuário anônimo") {
When("o usuário anônimo tenta abrir a página de visualização da tarefa") {
val response = openViewTaskPage(Tasks.WriteExampleApp.ID)
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"))
}
}
}
|
Quinto, temos que escrever um cenário que especifique o comportamento do sistema sob especificação quando um usuário registrado abre a página de visualização da tarefa. Podemos escrever este cenário seguindo estes passos:
- Adicionar um novo bloco
Given / When / Then à nossa classe de especificação.
- Configurar o usuário autenticado utilizando
user(Users.JohnDoe.EMAIL_ADDRESS) do SecurityMockMvcRequestPostProcessors (ou @WithUserDetails quando usamos estilos baseados em método como AnnotationSpec).
- Enviar uma requisição
GET ao caminho /task/1 e armazenar o objeto ResultActions retornado na variável response.
- Verificar se o método controlador testado retorna o código de status HTTP 200 (
status().isOk).
- Garantir que o método controlador testado renderize a view de visualização de tarefa (
task/view).
- Verificar se o método controlador testado encaminha o usuário para a página da view de tarefa.
- Garantir que o método controlador testado exiba as informações completas da tarefa encontrada no atributo
task do model.
- Verificar se o método controlador testado exibe as informações de uma tag.
- Garantir que o método controlador testado exiba as informações da tag correta.
Após termos escrito este cenário, o código-fonte completo da nossa classe de especificação 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
173
174
|
package com.testwithspring.master.web
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 io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.contains
import org.hamcrest.Matchers.hasProperty
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.security.core.userdetails.UserDetailsService
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.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
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",
"classpath:/com/testwithspring/master/tasks.sql"
],
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class ShowTaskSpec(
private val webAppContext: WebApplicationContext,
private val userDetailsService: UserDetailsService
) : 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 openViewTaskPage(taskId: Long): ResultActions =
mockMvc.perform(get("/task/{taskId}", taskId))
fun openViewTaskPageAsUser(taskId: Long, email: String): ResultActions {
val userDetails = userDetailsService.loadUserByUsername(email)
return mockMvc.perform(
get("/task/{taskId}", taskId)
.with(user(userDetails))
)
}
Given("um usuário anônimo") {
When("o usuário anônimo tenta abrir a página de visualização da tarefa") {
val response = openViewTaskPage(Tasks.WriteExampleApp.ID)
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"))
}
}
}
Given("um usuário registrado autenticado") {
When("a tarefa solicitada é encontrada") {
val response = openViewTaskPageAsUser(
Tasks.WriteExampleApp.ID,
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 visualização de tarefa") {
response.andExpect(view().name("task/view"))
}
Then("deve encaminhar o usuário para a página de visualização de tarefa") {
response.andExpect(forwardedUrl("/WEB-INF/jsp/task/view.jsp"))
}
Then("deve exibir as informações da tarefa encontrada") {
response.andExpect(
model().attribute(
"task",
allOf(
hasProperty(
"assignee",
allOf(
hasProperty("name", `is`(Tasks.WriteExampleApp.Assignee.NAME)),
hasProperty("userId", `is`(Tasks.WriteExampleApp.Assignee.ID))
)
),
hasProperty(
"closer",
allOf(
hasProperty("name", `is`(Tasks.WriteExampleApp.Closer.NAME)),
hasProperty("userId", `is`(Tasks.WriteExampleApp.Closer.ID))
)
),
hasProperty("creationTime", `is`(Tasks.WriteExampleApp.CREATION_TIME)),
hasProperty(
"creator",
allOf(
hasProperty("name", `is`(Tasks.WriteExampleApp.Creator.NAME)),
hasProperty("userId", `is`(Tasks.WriteExampleApp.Creator.ID))
)
),
hasProperty("id", `is`(Tasks.WriteExampleApp.ID)),
hasProperty("modificationTime", `is`(Tasks.WriteExampleApp.MODIFICATION_TIME)),
hasProperty(
"modifier",
allOf(
hasProperty("name", `is`(Tasks.WriteExampleApp.Modifier.NAME)),
hasProperty("userId", `is`(Tasks.WriteExampleApp.Modifier.ID))
)
),
hasProperty("title", `is`(Tasks.WriteExampleApp.TITLE)),
hasProperty("description", `is`(Tasks.WriteExampleApp.DESCRIPTION)),
hasProperty("status", `is`(Tasks.WriteExampleApp.STATUS)),
hasProperty("resolution", `is`(Tasks.WriteExampleApp.RESOLUTION))
)
)
)
}
Then("deve exibir uma tag da tarefa encontrada") {
response.andExpect(
model().attribute("task", hasProperty("tags", hasSize(1)))
)
}
Then("deve exibir as informações da tag encontrada") {
response.andExpect(
model().attribute(
"task",
hasProperty(
"tags",
contains(
allOf(
hasProperty("id", `is`(Tasks.WriteExampleApp.Tags.Example.ID)),
hasProperty("name", `is`(Tasks.WriteExampleApp.Tags.Example.NAME))
)
)
)
)
)
}
}
}
})
|
Nota: Você também pode usar a anotação @WithUserDetails ao nível da classe de especificação (ou combinada com @ContextConfiguration), ou injetar o UserDetailsService com .with(user(userDetails)) em lambdas de BehaviorSpec para alternar papéis de usuário dentro da mesma especificação.
Vamos avançar e descobrir como podemos estruturar nossas classes de especificação quando estamos escrevendo testes de integração para métodos controladores que renderizam dados.
Estruturando Nossas Classes de Especificação
Podemos estruturar nossas classes de especificação seguindo estas regras:
- Se o método controlador testado renderiza todos os dados encontrados em uma tabela do banco de dados, devemos escrever múltiplas classes de especificação (ou múltiplos contextos raiz
Given com datasets distintos), pois caso contrário não conseguiremos garantir que o sistema sob especificação funcione conforme o esperado quando a tabela do banco de dados está vazia e quando ela contém dados.
- Se o método controlador testado renderiza dados que atendem ao critério de busca fornecido, devemos escrever apenas uma classe de especificação. Não precisamos de múltiplas classes de especificação porque podemos construir nossos dados de teste e selecionar o critério de busca utilizado de uma forma que nos permita testar todos os cenários necessários usando múltiplos blocos
When e Then.
- 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 renderizam dados. Vamos resumir o que aprendemos nesta aula.
Resumo
Esta aula nos ensinou quatro coisas:
- Podemos autenticar o usuário de teste carregando o
UserDetails real do banco via UserDetailsService ou @WithUserDetails.
- Se o método controlador testado renderiza todos os dados encontrados em uma tabela do banco de dados, devemos separar claramente os cenários de tabela vazia e tabela populada.
- Se o método controlador testado renderiza dados que atendem a um critério de busca específico, devemos escrever apenas uma classe de especificação.
- Devemos escrever um cenário de especificação por papel de usuário (ex.: anônimo vs. usuário registrado).