Especificando Controladores Web com Kotest: Formulários

Depois de concluirmos esta aula, nós:

  • Saberemos como criar especificações no Kotest 6 para controladores web tradicionais do Spring MVC que processam submissões de formulários (POST).
  • Compreenderemos como verificar erros de validação Bean Validation, interações com o serviço de domínio (verify), redirecionamentos (302 Found) e mensagens de feedback (flash attributes).

1. Conhecendo o Sistema Sob Especificação

Precisamos especificar o comportamento esperado do método handler que processa requisições HTTP POST enviadas para o caminho /task/create. O nome desse método é processCreateTaskForm(), e ele é implementado seguindo quatro passos:

  1. Se o formulário submetido possuir erros de validação (bindingResult.hasErrors()), retorna imediatamente o nome da view do formulário ("task/create").
  2. Salva a nova tarefa no banco de dados invocando o método create(formObject, loggedInUser) da interface TaskCrudService.
  3. Cria uma mensagem localizada de feedback informando que a tarefa foi criada e a adiciona como um flash attribute ("feedbackMessage") em RedirectAttributes.
  4. Redireciona a requisição ("redirect:/task/{taskId}") para a página que exibe os detalhes da tarefa recém-criada.

Veja o código-fonte do método processCreateTaskForm() em TaskCrudController:

 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
package com.testwithspring.master.web

import com.testwithspring.master.user.LoggedInUser
import com.testwithspring.master.task.TaskCrudService
import com.testwithspring.master.task.TaskFormDTO
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 os dados do formulário e declara as anotações de validação do Jakarta Bean Validation (@NotBlank, @Size):

 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? = null,

    @field:NotBlank
    @field:Size(max = 100)
    var title: String? = null
)

Vamos construir passo a passo a nossa especificação para o método processCreateTaskForm().


2. Especificando o Comportamento Esperado

Passo 1: Configurando o Bloco de Preparação (Given)

No bloco Given principal do caso de uso "Create a new task", registramos a mensagem "Task created" para a chave "feedback.message.task.created" no nosso StaticMessageSource:

1
2
3
4
5
6
7
    Given("Create a new task - The message source contains the feedback message") {
        messageSource.addMessage(
            "feedback.message.task.created",
            WebTestConfig.LOCALE,
            "Task created"
        )
    }

Passo 2: Especificando a Submissão de um Formulário Vazio (Erro de Validação)

Precisamos garantir que o método processCreateTaskForm() funcione conforme esperado quando o usuário submete um formulário vazio:

  1. Submetemos um formulário POST /task/create com description = "" e title = "".
  2. Garantimos que o controlador retorna o status HTTP 200 OK (re-renderizando o formulário na mesma requisição).
  3. Verificamos que o controlador renderiza a view "task/create".
  4. Garantimos que o atributo "task" do Model possui um erro de validação com código "NotBlank" no campo "title".
  5. Verificamos que os campos title e description do objeto "task" no Model permanecem vazios ("") e que o campo id permanece null.
  6. Garantimos que o controlador não invoca service.create(...) (verify(exactly = 0)).
 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
        When("A user submits an empty form") {
            val response = mockMvc.perform(
                post("/task/create")
                    .param("description", "")
                    .param("title", "")
            )

            Then("Should return the HTTP status code OK") {
                response.andExpect(status().isOk)
            }

            And("Should render the create task view") {
                response.andExpect(view().name("task/create"))
            }

            And("Should show a validation error about an empty title") {
                response.andExpect(
                    model().attributeHasFieldErrorCode("task", "title", `is`("NotBlank"))
                )
            }

            And("Should show empty title and description") {
                response.andExpect(
                    model().attribute(
                        "task",
                        allOf(
                            hasProperty("description", `is`("")),
                            hasProperty("title", `is`(""))
                        )
                    )
                )
            }

            And("Should not modify the id field") {
                response.andExpect(
                    model().attribute("task", hasProperty("id", nullValue()))
                )
            }

            And("Should not create a new task") {
                verify(exactly = 0) { service.create(any(), anyNullable()) }
            }
        }

Passo 3: Especificando a Submissão de um Formulário Válido (Sucesso e Redirect)

Agora garantimos que o método processCreateTaskForm() funcione conforme esperado quando o usuário submete informações válidas:

  1. Configuramos o mock service.create(...) para retornar um TaskDTO(id = 1L, title = "Create a new task").
  2. Submetemos uma requisição POST /task/create com description = "This is a new task" e title = "Create a new task".
  3. Verificamos com verify(exactly = 1) que service.create() foi chamado recebendo um TaskFormDTO com id == null, description == "This is a new task" e title == "Create a new task".
  4. Verificamos que o controlador retorna o código de status HTTP 302 Found (status().isFound).
  5. Garantimos que o controlador redireciona o usuário para a view "redirect:/task/{taskId}" com o atributo de modelo taskId = "1".
  6. Verificamos que o controlador adiciona o flash attribute "feedbackMessage" com o valor "Task created".

Veja como fica a especificação completa na classe TaskCrudControllerFormSpec:

  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
package com.testwithspring.master.web

import com.testwithspring.master.UnitTest
import com.testwithspring.master.task.TaskCrudService
import com.testwithspring.master.task.TaskDTO
import com.testwithspring.master.task.TaskFormDTO
import com.testwithspring.master.web.WebTestConfig.exceptionResolver
import com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
import com.testwithspring.master.web.WebTestConfig.jspViewResolver
import io.kotest.core.annotation.Tags
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.hasProperty
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.springframework.context.support.StaticMessageSource
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.status
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.view
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tags("UnitTest")
class TaskCrudControllerFormSpec : BehaviorSpec({

    isolationMode = IsolationMode.InstancePerLeaf

    val messageSource = StaticMessageSource()
    val service = mockk<TaskCrudService>()
    val mockMvc = MockMvcBuilders
        .standaloneSetup(TaskCrudController(service, messageSource))
        .setHandlerExceptionResolvers(exceptionResolver())
        .setLocaleResolver(fixedLocaleResolver())
        .setViewResolvers(jspViewResolver())
        .build()

    Given("Create a new task - The message source contains the feedback message") {
        messageSource.addMessage(
            "feedback.message.task.created",
            WebTestConfig.LOCALE,
            "Task created"
        )

        When("A user submits an empty form") {
            val response = mockMvc.perform(
                post("/task/create")
                    .param("description", "")
                    .param("title", "")
            )

            Then("Should return the HTTP status code OK") {
                response.andExpect(status().isOk)
            }

            And("Should render the create task view") {
                response.andExpect(view().name("task/create"))
            }

            And("Should show a validation error about an empty title") {
                response.andExpect(
                    model().attributeHasFieldErrorCode("task", "title", `is`("NotBlank"))
                )
            }

            And("Should show empty title and description") {
                response.andExpect(
                    model().attribute(
                        "task",
                        allOf(
                            hasProperty("description", `is`("")),
                            hasProperty("title", `is`(""))
                        )
                    )
                )
            }

            And("Should not modify the id field") {
                response.andExpect(
                    model().attribute("task", hasProperty("id", nullValue()))
                )
            }

            And("Should not create a new task") {
                verify(exactly = 0) { service.create(any(), anyNullable()) }
            }
        }

        When("A user submits the create task form by using valid information") {
            every {
                service.create(any<TaskFormDTO>(), anyNullable())
            } returns TaskDTO(
                id = 1L,
                title = "Create a new task"
            )

            val response = mockMvc.perform(
                post("/task/create")
                    .param("description", "This is a new task")
                    .param("title", "Create a new task")
            )

            Then("Should create a new task by using the correct information") {
                verify(exactly = 1) {
                    service.create(
                        withArg { saved ->
                            saved.id shouldBe null
                            saved.description shouldBe "This is a new task"
                            saved.title shouldBe "Create a new task"
                        },
                        anyNullable()
                    )
                }
            }

            And("Should return the HTTP status code found (302)") {
                response.andExpect(status().isFound)
            }

            And("Should redirect the user to the view task view") {
                response.andExpect(view().name("redirect:/task/{taskId}"))
                    .andExpect(model().attribute("taskId", `is`("1")))
            }

            And("Should show feedback message to the user") {
                response.andExpect(
                    flash().attribute("feedbackMessage", "Task created")
                )
            }
        }
    }
})

[!TIP] Se uma especificação tiver muitos cenários de validação (por exemplo, título em branco, título longo demais, descrição longa demais), você pode separar cada cenário em seu próprio bloco Given (ou usar withData para os casos inválidos) para manter cada bloco curto e focado!


Resumo

Nesta aula, aprendemos dois pontos essenciais:

  • Quando nosso bloco Given compartilha uma configuração comum a vários fluxos do mesmo formulário (como a mensagem no StaticMessageSource), podemos aninhar múltiplos blocos When sob esse Given.
  • Combinamos as verificações do MockMvc (status(), view(), model(), flash()) com a verificação de argumentos do MockK (verify(exactly = 1) { service.create(withArg { ... }, ...) }) para validar tanto a resposta HTTP quanto os dados enviados à camada de serviço.