Testes Unitários de Controladores Web: Formulários e Validação

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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!


Cenário 1: Submetendo um Formulário Vazio

Quando o usuário submete um formulário de criação de tarefa vazio (title = "" e description = ""), nossa aplicação deve:

  1. Retornar o código de status HTTP 200 (OK).
  2. Renderizar a view do formulário de criação (task/create) e encaminhar a requisição para /WEB-INF/jsp/task/create.jsp.
  3. Retornar um erro de validação @NotBlank no campo title do atributo de model task.
  4. Retornar o objeto de formulário task no Model com os campos title e description vazios.
  5. 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.

Passo 1: Criando a Classe Aninhada e o Método Helper submitEmptyCreateTaskForm()

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
                        )
                    )
            }

Passo 4: Verificando os Valores do Objeto de Formulário no Model

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()) }
            }

Cenário 2: Submetendo um Formulário com Campos Longos Demais

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:

  1. Retornar o código de status HTTP 200 (OK).
  2. Renderizar a view do formulário de criação (task/create).
  3. Retornar 2 erros de validação (Size) nos campos title e description.
  4. 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).
  5. 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!


Cenário 3 e 4: Submetendo um Formulário Válido

Quando o usuário submete um formulário de criação de tarefa válido, o método handler submitCreateTaskForm() deve:

  1. Criar uma nova tarefa chamando crudService.create(form) com o title e description corretos.
  2. Retornar o código de status HTTP 302 (Found — redirecionamento).
  3. Redirecionar a requisição para a página da tarefa recém-criada (redirect:/task/1, encaminhando para a URL /task/1).
  4. 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:

  1. 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").
  2. Verificando Flash Attributes (flash().attribute(...)): O método estático MockMvcResultMatchers.flash() retorna um FlashAttributeResultMatchers que nos permite inspecionar atributos armazenados em RedirectAttributes.addFlashAttribute(...).
  3. 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(...)).