Ao concluirmos esta aula, nós:
- Saberemos como “submeter um formulário” utilizando o Spring MVC Test framework.
- Compreenderemos como podemos verificar que um campo do formulário possui erros de validação.
- Saberemos como verificar que a requisição foi redirecionada para o caminho esperado.
- Saberemos como escrever asserções para mensagens flash (flash attributes) exibidas ao usuário após o redirecionamento.
Introdução ao Sistema Sob Teste (SUT)
Nesta aula, escreveremos testes unitários para um método handler que processa a submissão do formulário de criação de tarefas (createTaskForm). O método sob teste utiliza a seguinte classe de formulário (TaskForm):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
package com.testwithspring.starter.web
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
class TaskForm {
@field:Size(max = 500)
var description: String? = null
@field:NotBlank
@field:Size(max = 100)
var title: String? = null
}
|
Nota sobre Anotações de Validação em Kotlin: Em Kotlin, quando anotamos uma propriedade com anotações da Jakarta Bean Validation (jakarta.validation.constraints.*), devemos usar o use-site target @field: (por exemplo, @field:NotBlank e @field:Size(max = 100)) para garantir que a anotação seja aplicada ao campo subjacente na JVM onde o LocalValidatorFactoryBean do Spring a inspeciona.
O método handler submitCreateTaskForm() da classe TaskCrudController recebe um objeto @Valid @ModelAttribute("task") form: TaskForm, verifica erros de validação no BindingResult e, caso os dados sejam válidos, cria uma nova tarefa e redireciona o usuário:
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
|
package com.testwithspring.starter.web
import jakarta.validation.Valid
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.bind.annotation.RequestMapping
import org.springframework.web.servlet.mvc.support.RedirectAttributes
@Controller
@RequestMapping("/task")
class TaskCrudController(
private val crudService: TaskCrudService
) {
companion object {
private const val FEEDBACK_MESSAGE_KEY_TASK_CREATED = "feedback.message.task.created"
private const val FLASH_MESSAGE_KEY_FEEDBACK = "feedbackMessage"
private const val MODEL_ATTRIBUTE_TASK_FORM = "task"
private const val VIEW_CREATE_TASK_FORM = "task/create"
}
@PostMapping("/create")
fun submitCreateTaskForm(
@Valid @ModelAttribute(MODEL_ATTRIBUTE_TASK_FORM) form: TaskForm,
bindingResult: BindingResult,
redirectAttributes: RedirectAttributes
): String {
if (bindingResult.hasErrors()) {
return VIEW_CREATE_TASK_FORM
}
val created = crudService.create(form)
addFeedbackMessage(
redirectAttributes,
FEEDBACK_MESSAGE_KEY_TASK_CREATED,
created.title
)
return createRedirectViewPath("/task/${created.id}")
}
private fun addFeedbackMessage(
model: RedirectAttributes,
code: String,
vararg params: Any?
) {
// Resolve a mensagem internacionalizada via MessageSource e adiciona ao Flash Attribute "feedbackMessage"
}
private fun createRedirectViewPath(path: String): String {
return "redirect:$path"
}
}
|
Quando iniciarmos a escrita dos testes unitários para este método na classe TaskCrudControllerTest, sua estrutura inicial é a 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
|
package com.testwithspring.starter.web
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 FEEDBACK_MESSAGE_KEY_TASK_CREATED = "feedback.message.task.created"
private const val FEEDBACK_MESSAGE_TASK_CREATED = "Uma nova tarefa foi criada!"
private const val FLASH_MESSAGE_KEY_FEEDBACK = "feedbackMessage"
private const val FIELD_NAME_DESCRIPTION = "description"
private const val FIELD_NAME_TITLE = "title"
private const val OBJECT_NAME_TASK_FORM = "task"
private const val VALIDATION_ERROR_NOT_BLANK = "NotBlank"
private const val VALIDATION_ERROR_SIZE = "Size"
private const val VIEW_CREATE_TASK_FORM = "task/create"
}
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, messageSource))
.setViewResolvers(WebTestConfig.jspViewResolver())
.setLocaleResolver(WebTestConfig.fixedLocaleResolver(Locale.ENGLISH))
.build()
}
@Nested
@DisplayName("Submeter o formulário de criação de tarefa")
inner class SubmitCreateTaskForm {
// Os casos de teste ficarão aqui
}
}
|
Precisamos testar quatro cenários distintos ao submeter o formulário de criação de tarefas:
- Quando o usuário submete um formulário vazio (sem título nem descrição), o controlador deve retornar os erros de validação para o usuário.
- Quando o usuário submete um formulário com valores que excedem o tamanho máximo permitido (
title > 100 caracteres e description > 500 caracteres), o controlador deve retornar erros de validação de tamanho.
- Quando o usuário submete um formulário válido, o controlador deve redirecionar o usuário para a página que exibe as informações da tarefa recém-criada e adicionar uma mensagem flash de feedback.
- Quando o usuário submete um formulário válido, o controlador deve repassar o objeto
TaskForm com os valores corretos para o TaskCrudService.
Vamos implementar os testes para cada um desses cenários passo a passo!
Quando o usuário submete um formulário de criação de tarefa vazio (title = "" e description = ""), nossa aplicação deve:
- Retornar o código de status HTTP 200 (
OK).
- Renderizar a view do formulário de criação (
task/create) e encaminhar a requisição para /WEB-INF/jsp/task/create.jsp.
- Retornar um erro de validação
@NotBlank no campo title do atributo de model task.
- Retornar o objeto de formulário
task no Model com os campos title e description vazios.
- Não criar uma nova tarefa (não invocar
crudService.create()).
Vamos escrever uma classe de teste aninhada (WhenEmptyFormIsSubmitted) e implementar os testes passo a passo.
Como todos os nossos métodos de teste deste cenário precisam submeter um formulário vazio, adicionaremos um método privado submitEmptyCreateTaskForm(): ResultActions que envia uma requisição POST para /task/create configurando os parâmetros do formulário através do método param() de MockHttpServletRequestBuilder:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@Nested
@DisplayName("Submeter o formulário de criação de tarefa")
inner class SubmitCreateTaskForm {
@Nested
@DisplayName("Quando um formulário vazio é submetido")
inner class WhenEmptyFormIsSubmitted {
private fun submitEmptyCreateTaskForm(): ResultActions {
return mockMvc.perform(
post("/task/create")
.param(FIELD_NAME_TITLE, "")
.param(FIELD_NAME_DESCRIPTION, "")
)
}
}
}
|
Passo 2: Escrevendo Asserções para Status HTTP e View
Adicionamos os dois primeiros métodos de teste para garantir que o código HTTP retornado seja 200 (OK) e que o formulário seja re-exibido para o usuário:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
@Test
@DisplayName("Deve retornar o status HTTP 200 OK")
fun shouldReturnHttpStatusCodeOk() {
submitEmptyCreateTaskForm()
.andExpect(status().isOk)
}
@Test
@DisplayName("Deve renderizar a view do formulário de criação de tarefa")
fun shouldRenderCreateTaskFormView() {
submitEmptyCreateTaskForm()
.andExpect(view().name(VIEW_CREATE_TASK_FORM))
.andExpect(forwardedUrl("/WEB-INF/jsp/task/create.jsp"))
}
|
Passo 3: Verificando o Erro de Validação no Campo title
Para verificar que o atributo task do Model possui exatamente um erro de validação e que o campo title falhou na restrição @NotBlank, utilizamos os métodos attributeErrorCount() e attributeHasFieldErrorCode() da classe ModelResultMatchers (acessada via MockMvcResultMatchers.model()):
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Test
@DisplayName("Deve retornar erro de validação informando que o título não pode estar em branco")
fun shouldReturnValidationErrorAboutEmptyTitle() {
submitEmptyCreateTaskForm()
.andExpect(model().attributeErrorCount(OBJECT_NAME_TASK_FORM, 1))
.andExpect(
model().attributeHasFieldErrorCode(
OBJECT_NAME_TASK_FORM,
FIELD_NAME_TITLE,
VALIDATION_ERROR_NOT_BLANK
)
)
}
|
Quando a validação falha, o formulário deve manter os valores submetidos pelo usuário no atributo task do Model:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
@Test
@DisplayName("Deve exibir um formulário de criação de tarefa vazio")
fun shouldShowEmptyCreateTaskForm() {
submitEmptyCreateTaskForm()
.andExpect(
model().attribute(
OBJECT_NAME_TASK_FORM,
allOf(
hasProperty(FIELD_NAME_TITLE, equalTo("")),
hasProperty(FIELD_NAME_DESCRIPTION, equalTo(""))
)
)
)
}
|
Passo 5: Verificando que Nenhuma Tarefa Foi Criada
Por fim, garantimos com o verify(exactly = 0) do MockK que o método crudService.create() nunca é chamado quando o formulário possui erros de validação:
1
2
3
4
5
6
7
|
@Test
@DisplayName("Não deve criar uma nova tarefa")
fun shouldNotCreateNewTask() {
submitEmptyCreateTaskForm()
verify(exactly = 0) { crudService.create(any()) }
}
|
O segundo cenário ocorre quando o usuário preenche os campos title (máximo 100 caracteres) e description (máximo 500 caracteres) com strings que ultrapassam os limites configurados na anotação @Size. Nesse caso, o controlador deve:
- Retornar o código de status HTTP 200 (
OK).
- Renderizar a view do formulário de criação (
task/create).
- Retornar 2 erros de validação (
Size) nos campos title e description.
- Manter no atributo
task do Model as strings enviadas pelo usuário (para que o usuário possa editá-las sem perder o que digitou).
- Não criar uma nova tarefa.
Vamos adicionar uma nova classe aninhada WhenFormWithTooLongFieldValuesIsSubmitted:
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
|
@Nested
@DisplayName("Quando um formulário com valores longos demais é submetido")
inner class WhenFormWithTooLongFieldValuesIsSubmitted {
private val tooLongTitle = "t".repeat(101)
private val tooLongDescription = "d".repeat(501)
private fun submitCreateTaskFormWithTooLongValues(): ResultActions {
return mockMvc.perform(
post("/task/create")
.param(FIELD_NAME_TITLE, tooLongTitle)
.param(FIELD_NAME_DESCRIPTION, tooLongDescription)
)
}
@Test
@DisplayName("Deve retornar o status HTTP 200 OK")
fun shouldReturnHttpStatusCodeOk() {
submitCreateTaskFormWithTooLongValues()
.andExpect(status().isOk)
}
@Test
@DisplayName("Deve renderizar a view do formulário de criação de tarefa")
fun shouldRenderCreateTaskFormView() {
submitCreateTaskFormWithTooLongValues()
.andExpect(view().name(VIEW_CREATE_TASK_FORM))
.andExpect(forwardedUrl("/WEB-INF/jsp/task/create.jsp"))
}
@Test
@DisplayName("Deve retornar erros de validação sobre o tamanho do título e da descrição")
fun shouldReturnValidationErrorsAboutTitleAndDescriptionSize() {
submitCreateTaskFormWithTooLongValues()
.andExpect(model().attributeErrorCount(OBJECT_NAME_TASK_FORM, 2))
.andExpect(
model().attributeHasFieldErrorCode(
OBJECT_NAME_TASK_FORM,
FIELD_NAME_TITLE,
VALIDATION_ERROR_SIZE
)
)
.andExpect(
model().attributeHasFieldErrorCode(
OBJECT_NAME_TASK_FORM,
FIELD_NAME_DESCRIPTION,
VALIDATION_ERROR_SIZE
)
)
}
@Test
@DisplayName("Deve preservar os valores submetidos no formulário")
fun shouldPopulateCreateTaskFormWithSubmittedValues() {
submitCreateTaskFormWithTooLongValues()
.andExpect(
model().attribute(
OBJECT_NAME_TASK_FORM,
allOf(
hasProperty(FIELD_NAME_TITLE, equalTo(tooLongTitle)),
hasProperty(FIELD_NAME_DESCRIPTION, equalTo(tooLongDescription))
)
)
)
}
@Test
@DisplayName("Não deve criar uma nova tarefa")
fun shouldNotCreateNewTask() {
submitCreateTaskFormWithTooLongValues()
verify(exactly = 0) { crudService.create(any()) }
}
}
|
Dica de Kotlin: No curso original em Java 8, precisávamos de uma biblioteca utilitária customizada (TestStringUtil.createStringWithLength(101)) para gerar strings de tamanho específico. Em Kotlin, a biblioteca padrão oferece a função nativa "t".repeat(101), tornando o teste muito mais conciso e legível!
Quando o usuário submete um formulário de criação de tarefa válido, o método handler submitCreateTaskForm() deve:
- Criar uma nova tarefa chamando
crudService.create(form) com o title e description corretos.
- Retornar o código de status HTTP 302 (
Found — redirecionamento).
- Redirecionar a requisição para a página da tarefa recém-criada (
redirect:/task/1, encaminhando para a URL /task/1).
- Adicionar uma mensagem de feedback como flash attribute (
feedbackMessage).
Vamos criar a classe aninhada WhenValidFormIsSubmitted e configurar o comportamento dos nossos test doubles no método @BeforeEach:
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
|
@Nested
@DisplayName("Quando um formulário válido é submetido")
inner class WhenValidFormIsSubmitted {
private val createdTaskId = 1L
private val title = "Escrever aula sobre formulários"
private val description = "Cobrir validação, redirecionamento e flash attributes"
@BeforeEach
fun configureSystemUnderTest() {
every { crudService.create(any()) } returns TaskDTO(
id = createdTaskId,
title = title,
description = description
)
messageSource.addMessage(
FEEDBACK_MESSAGE_KEY_TASK_CREATED,
Locale.ENGLISH,
FEEDBACK_MESSAGE_TASK_CREATED
)
}
private fun submitValidCreateTaskForm(): ResultActions {
return mockMvc.perform(
post("/task/create")
.param(FIELD_NAME_TITLE, title)
.param(FIELD_NAME_DESCRIPTION, description)
)
}
|
Agora vamos escrever os testes que verificam o redirecionamento, a mensagem flash e a interação com o serviço:
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
|
@Test
@DisplayName("Deve retornar o status HTTP 302 Found")
fun shouldReturnHttpStatusCodeFound() {
submitValidCreateTaskForm()
.andExpect(status().isFound)
}
@Test
@DisplayName("Deve redirecionar o usuário para a view de visualização da tarefa criada")
fun shouldRedirectUserToViewTaskView() {
submitValidCreateTaskForm()
.andExpect(view().name("redirect:/task/$createdTaskId"))
.andExpect(redirectedUrl("/task/$createdTaskId"))
}
@Test
@DisplayName("Deve adicionar a mensagem de feedback como flash attribute")
fun shouldAddFeedbackMessageAsFlashAttribute() {
submitValidCreateTaskForm()
.andExpect(
flash().attribute(
FLASH_MESSAGE_KEY_FEEDBACK,
equalTo(FEEDBACK_MESSAGE_TASK_CREATED)
)
)
}
@Test
@DisplayName("Deve criar uma nova tarefa com o título e descrição corretos")
fun shouldCreateNewTaskWithCorrectTitleAndDescription() {
submitValidCreateTaskForm()
verify(exactly = 1) {
crudService.create(withArg { form ->
assertThat(form.title).isEqualTo(title)
assertThat(form.description).isEqualTo(description)
})
}
}
}
|
Observe três pontos importantes nesses testes:
- Verificando Redirecionamentos (
view().name("redirect:...") e redirectedUrl(...)): No Spring MVC, quando um controlador retorna "redirect:/task/1", o nome lógico da view começa com o prefixo redirect:. Podemos verificar tanto o nome da view com view().name("redirect:/task/1") quanto a URL efetiva de redirecionamento com redirectedUrl("/task/1").
- Verificando Flash Attributes (
flash().attribute(...)): O método estático MockMvcResultMatchers.flash() retorna um FlashAttributeResultMatchers que nos permite inspecionar atributos armazenados em RedirectAttributes.addFlashAttribute(...).
- Inspecionando o Argumento Passado ao Serviço com
withArg do MockK: Em vez de criar asserções separadas com ArgumentCaptor, usamos withArg { form -> ... } do MockK combinado com assertThat do AssertJ para inspecionar os campos do objeto TaskForm instanciado pelo data binder do Spring MVC.
Resumo
Esta aula nos ensinou quatro conceitos essenciais:
- Podemos configurar os valores dos campos de um formulário HTML usando o método
param() da classe MockHttpServletRequestBuilder.
- Podemos verificar que um campo específico do formulário possui determinado erro de validação usando o método
attributeHasFieldErrorCode() da classe ModelResultMatchers.
- Podemos construir o nome da view de redirecionamento adicionando o prefixo
"redirect:" ao caminho esperado e validar o cabeçalho de destino com redirectedUrl().
- Podemos escrever asserções para as mensagens flash exibidas após o redirecionamento usando o método
attribute() da classe FlashAttributeResultMatchers (flash().attribute(...)).