Especificando Controladores Web com Kotest: Renderização de Dados

Depois de concluirmos esta aula, nós:

  • Saberemos como criar especificações no Kotest 6 para controladores web tradicionais do Spring MVC que buscam dados e renderizam uma view.
  • Compreenderemos como documentar o sistema sob especificação de forma executável usando os blocos Given, When, Then e And.

1. Conhecendo o Sistema Sob Especificação

Precisamos especificar o comportamento esperado do método handler que processa requisições HTTP GET enviadas para o caminho /task/{taskId}. O nome desse método é showTask(), e ele executa três passos:

  1. Busca a tarefa no banco de dados invocando o método findById(taskId) da interface TaskCrudService.
  2. Adiciona a tarefa encontrada a um atributo do Model chamado "task".
  3. Retorna o nome da view ("task/view") que renderiza os detalhes da tarefa encontrada.

Veja o código-fonte do nosso controlador 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
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 DTO que contém os dados completos de uma 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(
    var id: Long? = null,
    var title: String? = null,
    var description: String? = null,
    var status: TaskStatus? = null,
    var resolution: TaskResolution? = null,
    var creationTime: ZonedDateTime? = null,
    var modificationTime: ZonedDateTime? = null,
    var creator: PersonDTO? = null,
    var modifier: PersonDTO? = null,
    var assignee: PersonDTO? = null,
    var closer: PersonDTO? = null,
    var tags: MutableList<TagDTO> = mutableListOf()
)

Onde PersonDTO e TagDTO são objetos simples:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package com.testwithspring.master.task

data class PersonDTO(
    var userId: Long? = null,
    var name: String? = null
)

data class TagDTO(
    var id: Long? = null,
    var name: String? = null
)

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


2. Especificando o Comportamento Esperado

Podemos especificar o comportamento do método showTask() na classe TaskCrudControllerSpec seguindo três etapas:

Passo 1: Estrutura Inicial da Especificação

Primeiro, configuramos o MockMvc em modo standalone (como aprendemos na aula anterior), declaramos as constantes CREATION_TIME e MODIFICATION_TIME e abrimos o contexto da funcionalidade "Show task":

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

import com.testwithspring.master.UnitTest
import com.testwithspring.master.task.TaskCrudService
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.mockk.mockk
import org.springframework.context.support.StaticMessageSource
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.time.ZonedDateTime

@Tags("UnitTest")
class TaskCrudControllerSpec : 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("Show task") {
        // Cenários virão a seguir
    }
}) {
    companion object {
        private val CREATION_TIME: ZonedDateTime = ZonedDateTime.now().minusDays(4)
        private val MODIFICATION_TIME: ZonedDateTime = CREATION_TIME.plusDays(2)
    }
}

Passo 2: Especificando o Cenário em que a Tarefa Não É Encontrada (404)

Precisamos verificar que o método showTask() se comporta corretamente quando a tarefa solicitada não existe:

  1. Garantimos que o mock TaskCrudService lance uma NotFoundException quando findById(99L) for invocado.
  2. Enviamos uma requisição GET /task/99 e guardamos o objeto ResultActions retornado na variável response.
  3. Verificamos que o controlador retorna o código de status HTTP 404 Not Found.
  4. Garantimos que o controlador renderiza a view de erro "error/404".
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    Given("Show task - when no task is found") {
        every { service.findById(99L) } throws NotFoundException("")

        When("A user tries to open the view task page for task 99") {
            val response = mockMvc.perform(get("/task/{taskId}", 99L))

            Then("Should return the HTTP status code not found (404)") {
                response.andExpect(status().isNotFound)
            }

            And("Should render the 404 view") {
                response.andExpect(view().name("error/404"))
            }
        }
    }

Passo 3: Especificando o Cenário em que a Tarefa É Encontrada (200 OK)

Agora verificamos que o método showTask() funciona como esperado quando a tarefa solicitada é encontrada:

  1. Configuramos o mock TaskCrudService para retornar uma tarefa fechada (TaskStatus.CLOSED, TaskResolution.DONE) contendo uma tag quando findById(1L) for invocado (construída com o TaskDTOBuilder).
  2. Enviamos uma requisição GET /task/1 e armazenamos o ResultActions em response.
  3. Verificamos que o controlador retorna o status HTTP 200 OK.
  4. Garantimos que o controlador renderiza a view "task/view".
  5. Verificamos que o atributo "task" do Model exibe todas as informações da tarefa encontrada.
  6. Garantimos que o atributo "task" possui exatamente 1 tag.
  7. Verificamos que a tag exibida possui o id = 33L e name = "testing".

Veja como fica a classe TaskCrudControllerSpec completa:

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

import com.testwithspring.master.NotFoundException
import com.testwithspring.master.UnitTest
import com.testwithspring.master.task.PersonDTO
import com.testwithspring.master.task.TagDTO
import com.testwithspring.master.task.TaskCrudService
import com.testwithspring.master.task.TaskDTOBuilder
import com.testwithspring.master.task.TaskResolution
import com.testwithspring.master.task.TaskStatus
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.mockk.every
import io.mockk.mockk
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.context.support.StaticMessageSource
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
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
import java.time.ZonedDateTime

@Tags("UnitTest")
class TaskCrudControllerSpec : 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("Show task - when no task is found") {
        every { service.findById(99L) } throws NotFoundException("")

        When("A user tries to open the view task page") {
            val response = mockMvc.perform(get("/task/{taskId}", 99L))

            Then("Should return the HTTP status code not found") {
                response.andExpect(status().isNotFound)
            }

            And("Should render the 404 view") {
                response.andExpect(view().name("error/404"))
            }
        }
    }

    Given("Show task - when a closed task is found and it has one tag") {
        every { service.findById(1L) } returns TaskDTOBuilder()
            .withId(1L)
            .withAssignee(PersonDTO(userId = 44L, name = "Anne Assignee"))
            .withCreationTime(CREATION_TIME)
            .withCreator(PersonDTO(userId = 99L, name = "John Doe"))
            .withDescription("description")
            .withModificationTime(MODIFICATION_TIME)
            .withModifier(PersonDTO(userId = 33L, name = "Jane Doe"))
            .withResolutionDone(PersonDTO(userId = 931L, name = "Chris Closer"))
            .withTags(TagDTO(id = 33L, name = "testing"))
            .withTitle("title")
            .build()

        When("A user opens the view task page") {
            val response = mockMvc.perform(get("/task/{taskId}", 1L))

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

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

            And("Should display the information of the found task") {
                response.andExpect(
                    model().attribute(
                        "task",
                        allOf(
                            hasProperty(
                                "assignee",
                                allOf(
                                    hasProperty("name", `is`("Anne Assignee")),
                                    hasProperty("userId", `is`(44L))
                                )
                            ),
                            hasProperty(
                                "closer",
                                allOf(
                                    hasProperty("name", `is`("Chris Closer")),
                                    hasProperty("userId", `is`(931L))
                                )
                            ),
                            hasProperty("creationTime", `is`(CREATION_TIME)),
                            hasProperty(
                                "creator",
                                allOf(
                                    hasProperty("name", `is`("John Doe")),
                                    hasProperty("userId", `is`(99L))
                                )
                            ),
                            hasProperty("id", `is`(1L)),
                            hasProperty("modificationTime", `is`(MODIFICATION_TIME)),
                            hasProperty(
                                "modifier",
                                allOf(
                                    hasProperty("name", `is`("Jane Doe")),
                                    hasProperty("userId", `is`(33L))
                                )
                            ),
                            hasProperty("title", `is`("title")),
                            hasProperty("description", `is`("description")),
                            hasProperty("status", `is`(TaskStatus.CLOSED)),
                            hasProperty("resolution", `is`(TaskResolution.DONE))
                        )
                    )
                )
            }

            And("Should display one tag of the found task") {
                response.andExpect(
                    model().attribute("task", hasProperty("tags", hasSize<Any>(1)))
                )
            }

            And("Should display the tag of the found task") {
                response.andExpect(
                    model().attribute(
                        "task",
                        hasProperty(
                            "tags",
                            contains(
                                allOf(
                                    hasProperty("id", `is`(33L)),
                                    hasProperty("name", `is`("testing"))
                                )
                            )
                        )
                    )
                )
            }
        }
    }
}) {
    companion object {
        private val CREATION_TIME: ZonedDateTime = ZonedDateTime.now().minusDays(4)
        private val MODIFICATION_TIME: ZonedDateTime = CREATION_TIME.plusDays(2)
    }
}

[!NOTE] Observe três benefícios diretos dessa estrutura no Kotest:

  1. Utilizamos o Test Data Builder TaskDTOBuilder combinado com argumentos nomeados do Kotlin (PersonDTO(userId = 44L, name = "Anne Assignee")), mantendo a criação de dados limpa e expressiva.
  2. Dividimos o bloco Then em seções lógicas usando And("..."), onde cada seção valida uma única responsabilidade (status HTTP, nome da view, atributos da tarefa, tamanho da lista de tags e conteúdo da tag).
  3. Com descrições claras nos blocos Given, When, Then e And, nossa classe de teste se torna uma especificação executável que documenta o comportamento do controlador!

Resumo

Nesta aula, aprendemos três pontos essenciais:

  • Especificamos diferentes cenários de um controlador web estruturando blocos Given, When e Then no Kotest.
  • Quando uma verificação possui múltiplas responsabilidades (status HTTP, view e atributos do Model), dividimos o bloco Then em seções específicas usando And.
  • Boas descrições nos blocos transformam nossos testes unitários em documentação viva do sistema sob especificação.