Testes Unitários de Controladores Web: Renderização de Dados

Ao concluirmos esta aula, nós:

  • Saberemos como criar requisições HTTP (GET) que são processadas pelos métodos do controlador testado.
  • Seremos capazes de garantir que o código de status HTTP correto (200 OK) seja retornado.
  • Seremos capazes de verificar que a view correta (task/list, task/view) seja renderizada.
  • Seremos capazes de garantir que os atributos adicionados ao Model do Spring MVC contenham exatamente as informações esperadas.

[!NOTE] Esta aula assume que:


Aprendendo os Fundamentos

Antes de escrevermos testes unitários para um controlador Spring MVC, precisamos saber como enviar uma requisição HTTP ao controlador testado e como escrever asserções sobre a resposta retornada:

  1. Primeiro, criamos requisições HTTP utilizando os métodos fábrica estáticos fornecidos pela classe MockMvcRequestBuilders (get(), post(), put(), delete(), patch()). Esses métodos retornam um MockHttpServletRequestBuilder (ou MockMultipartHttpServletRequestBuilder), com o qual podemos configurar o corpo da requisição, adicionar cookies ou cabeçalhos HTTP e definir parâmetros de requisição. Depois de construir a requisição HTTP, nós a enviamos ao controlador testado invocando o método perform() da classe MockMvc.
  2. Segundo, escrevemos asserções para a resposta retornada invocando o método andExpect() da interface ResultActions (retornada por perform()). Esse método recebe um objeto ResultMatcher criado pelos métodos fábrica estáticos da classe MockMvcResultMatchers — como status(), view() e model(). Além disso, dentro de model().attribute(...), podemos combinar matchers do Hamcrest (hasSize, contains, allOf, hasProperty, is) para inspecionar profundamente os objetos do Model!

Vamos avançar e ver na prática como utilizar essas classes ao escrever testes unitários para controladores Spring MVC que leem informações do banco de dados.


Teste 1: Renderizando Todas as Tarefas Encontradas no Banco de Dados (showTaskList)

Precisamos escrever testes unitários para um método de controlador que processa requisições GET enviadas para o caminho "/". Esse método se chama showTaskList() e é implementado seguindo estes três passos:

  1. Busca todas as tarefas no banco de dados utilizando o método findAll() da interface TaskCrudService.
  2. Coloca as tarefas encontradas em um atributo do Model chamado "tasks".
  3. Retorna o nome lógico da view ("task/list") que renderiza a lista de tarefas.

O código-fonte do método do controlador testado é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.testwithspring.starter.spring.web

import org.springframework.context.MessageSource
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping

@Controller
class TaskCrudController(
    private val crudService: TaskCrudService,
    private val messageSource: MessageSource
) {

    @GetMapping("/")
    fun showTaskList(model: Model): String {
        val tasks: List<TaskListDTO> = crudService.findAll()
        model.addAttribute("tasks", tasks)
        return "task/list"
    }
}

A classe TaskListDTO é um DTO simples que contém as informações resumidas exibidas na página de listagem de tarefas:

1
2
3
4
5
6
7
package com.testwithspring.starter.spring.web

class TaskListDTO {
    var id: Long? = null
    var title: String? = null
    var status: TaskStatus? = null
}

A seguir, escreveremos testes unitários que garantem que o método showTaskList() funciona como esperado quando duas tarefas são encontradas. Podemos escrever esses testes seguindo quatro passos:

Passo 1: Configurar o Sistema Sob Teste no @BeforeEach

Primeiro, configuramos o Sistema Sob Teste no método anotado com @BeforeEach na raiz da classe TaskCrudControllerTest. Para este exemplo inicial, não precisamos sequer registrar componentes extras além do próprio controlador no standaloneSetup:

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

import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.springframework.context.support.StaticMessageSource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
class TaskCrudControllerTest {

    private lateinit var crudService: TaskCrudService
    private lateinit var messageSource: StaticMessageSource
    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        crudService = mockk(relaxed = true)
        messageSource = StaticMessageSource()

        val controller = TaskCrudController(
            crudService = crudService,
            messageSource = messageSource
        )
        mockMvc = MockMvcBuilders.standaloneSetup(controller)
            .build()
    }
}

[!NOTE] Neste teste de leitura, crudService atua como um Stub, mas usamos mockk() porque os demais testes desta mesma classe TaskCrudControllerTest (que veremos na próxima aula, sobre formulários) verificarão interações com verify { ... }.

Passo 2: Criar a hierarquia de classes internas (@Nested)

Segundo, criamos a hierarquia de classes aninhadas (ShowTaskList -> WhenTwoTasksAreFound) para organizar nossos casos de teste:

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

import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.springframework.context.support.StaticMessageSource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
class TaskCrudControllerTest {

    private lateinit var crudService: TaskCrudService
    private lateinit var messageSource: StaticMessageSource
    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        crudService = mockk(relaxed = true)
        messageSource = StaticMessageSource()

        val controller = TaskCrudController(
            crudService = crudService,
            messageSource = messageSource
        )
        mockMvc = MockMvcBuilders.standaloneSetup(controller)
            .build()
    }

    @Nested
    inner class ShowTaskList {

        @Nested
        inner class WhenTwoTasksAreFound {

            // Adicionaremos os testes aqui
        }
    }
}

Passo 3: Configurar o cenário em WhenTwoTasksAreFound

Terceiro, configuramos nossos casos de teste dentro da classe WhenTwoTasksAreFound seguindo estes passos:

  1. Adicionamos duas propriedades TaskListDTO (first e second) à classe WhenTwoTasksAreFound.
  2. Adicionamos um método fábrica privado chamado createTask() dentro de WhenTwoTasksAreFound que cria e retorna um objeto TaskListDTO.
  3. Adicionamos um método @BeforeEach fun returnTwoTasks() à classe WhenTwoTasksAreFound.
  4. Implementamos returnTwoTasks() criando dois objetos TaskListDTO e configurando crudService.findAll() com every { ... } returns listOf(first, second).

Após configurarmos o cenário, o código-fonte da nossa classe de teste 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
package com.testwithspring.starter.spring.web

import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.springframework.context.support.StaticMessageSource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
class TaskCrudControllerTest {

    private lateinit var crudService: TaskCrudService
    private lateinit var messageSource: StaticMessageSource
    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        crudService = mockk(relaxed = true)
        messageSource = StaticMessageSource()

        val controller = TaskCrudController(
            crudService = crudService,
            messageSource = messageSource
        )
        mockMvc = MockMvcBuilders.standaloneSetup(controller)
            .build()
    }

    @Nested
    inner class ShowTaskList {

        @Nested
        inner class WhenTwoTasksAreFound {

            private lateinit var first: TaskListDTO
            private lateinit var second: TaskListDTO

            @BeforeEach
            fun returnTwoTasks() {
                first = createTask(1L, "title", TaskStatus.OPEN)
                second = createTask(33L, "secondTask", TaskStatus.OPEN)

                every { crudService.findAll() } returns listOf(first, second)
            }

            private fun createTask(
                id: Long,
                title: String,
                status: TaskStatus
            ): TaskListDTO {
                val task = TaskListDTO()
                task.id = id
                task.title = title
                task.status = status
                return task
            }
        }
    }
}

Passo 4: Escrever os cinco testes unitários para showTaskList()

Quarto, escrevemos cinco métodos de teste unitário focados (um conceito lógico por teste!) para o método showTaskList():

  1. Verificamos que ele retorna o código de status HTTP 200 OK (status().isOk).
  2. Garantimos que ele renderiza a view de listagem de tarefas (view().name("task/list")).
  3. Verificamos que a lista de tarefas no atributo "tasks" do Model contém exatamente duas tarefas (hasSize(2)).
  4. Garantimos que as duas tarefas estão na ordem exata retornada pelo serviço (contains(first, second)).
  5. Verificamos que os objetos da lista renderizada contêm as propriedades corretas (id, title, status).

Depois de escrevermos esses cinco testes unitários, o código-fonte completo 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
package com.testwithspring.starter.spring.web

import io.mockk.every
import io.mockk.mockk
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.contains
import org.hamcrest.Matchers.hasItem
import org.hamcrest.Matchers.hasProperty
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.context.support.StaticMessageSource
import org.springframework.test.web.servlet.MockMvc
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

@Tag("unitTest")
class TaskCrudControllerTest {

    private lateinit var crudService: TaskCrudService
    private lateinit var messageSource: StaticMessageSource
    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        crudService = mockk(relaxed = true)
        messageSource = StaticMessageSource()

        val controller = TaskCrudController(
            crudService = crudService,
            messageSource = messageSource
        )
        mockMvc = MockMvcBuilders.standaloneSetup(controller)
            .build()
    }

    @Nested
    inner class ShowTaskList {

        @Nested
        inner class WhenTwoTasksAreFound {

            private lateinit var first: TaskListDTO
            private lateinit var second: TaskListDTO

            @BeforeEach
            fun returnTwoTasks() {
                first = createTask(1L, "title", TaskStatus.OPEN)
                second = createTask(33L, "secondTask", TaskStatus.OPEN)

                every { crudService.findAll() } returns listOf(first, second)
            }

            private fun createTask(
                id: Long,
                title: String,
                status: TaskStatus
            ): TaskListDTO {
                val task = TaskListDTO()
                task.id = id
                task.title = title
                task.status = status
                return task
            }

            @Test
            fun shouldReturnHttpStatusCodeOk() {
                mockMvc.perform(get("/"))
                    .andExpect(status().isOk)
            }

            @Test
            fun shouldRenderTaskListView() {
                mockMvc.perform(get("/"))
                    .andExpect(view().name("task/list"))
            }

            @Test
            fun shouldShowTaskListThatHasTwoTasks() {
                mockMvc.perform(get("/"))
                    .andExpect(model().attribute("tasks", hasSize<Any>(2)))
            }

            @Test
            fun shouldShowTwoTasksInCorrectOrder() {
                mockMvc.perform(get("/"))
                    .andExpect(model().attribute("tasks", contains(first, second)))
            }

            @Test
            fun shouldShowCorrectInformation() {
                mockMvc.perform(get("/"))
                    .andExpect(
                        model().attribute(
                            "tasks",
                            allOf(
                                hasItem(
                                    allOf(
                                        hasProperty("id", `is`(1L)),
                                        hasProperty("title", `is`("title")),
                                        hasProperty("status", `is`(TaskStatus.OPEN))
                                    )
                                ),
                                hasItem(
                                    allOf(
                                        hasProperty("id", `is`(33L)),
                                        hasProperty("title", `is`("secondTask")),
                                        hasProperty("status", `is`(TaskStatus.OPEN))
                                    )
                                )
                            )
                        )
                    )
            }
        }
    }
}

Vamos avançar e escrever testes unitários para um método de controlador que renderiza as informações detalhadas de uma única tarefa!


Teste 2: Renderizando as Informações de uma Única Tarefa (showTask)

Agora precisamos escrever testes unitários para um método de controlador que processa requisições GET enviadas para o caminho "/task/{taskId}". Esse método se chama showTask() e é implementado seguindo estes três passos:

  1. Busca a tarefa no banco de dados invocando o método findById() da interface TaskCrudService, passando o taskId recebido como variável de caminho (@PathVariable("taskId")).
  2. Coloca a tarefa encontrada em um atributo do Model chamado "task".
  3. Retorna o nome da view ("task/view") que renderiza os detalhes da tarefa encontrada.

O código-fonte do método showTask() no controlador é 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
package com.testwithspring.starter.spring.web

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: TaskDTO = crudService.findById(taskId)
        model.addAttribute("task", task)
        return "task/view"
    }
}

A classe TaskDTO é um DTO que contém todas as informações detalhadas de uma única tarefa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package com.testwithspring.starter.spring.web

class TaskDTO {
    var assigneeId: Long? = null
    var closerId: Long? = null
    var creatorId: Long? = null
    var description: String? = null
    var id: Long? = null
    var resolution: TaskResolution? = null
    var status: TaskStatus? = null
    var title: String? = null
}

Vamos escrever os testes unitários que garantem que o método showTask() funciona como esperado quando a tarefa solicitada é encontrada, seguindo três passos:

Passo 1: Adicionar a hierarquia de classes internas ShowTask -> WhenTaskIsFound

Primeiro, adicionamos a classe interna ShowTask e, dentro dela, a classe interna WhenTaskIsFound na nossa classe TaskCrudControllerTest:

1
2
3
4
5
6
7
8
9
    @Nested
    inner class ShowTask {

        @Nested
        inner class WhenTaskIsFound {

            // Adicionaremos os testes aqui
        }
    }

Passo 2: Configurar o cenário com TaskDTOBuilder

Segundo, configuramos nossos casos de teste dentro de WhenTaskIsFound seguindo estes passos:

  1. Adicionamos um método @BeforeEach fun returnFoundTask() à classe WhenTaskIsFound.
  2. Criamos um novo objeto TaskDTO utilizando um Test Data Builder (TaskDTOBuilder), pois o estado de um TaskDTO depende de invariantes de domínio (como estar aberto com status = OPEN, resolution = null e closerId = null).
  3. Configuramos crudService.findById(1L) com every { ... } returns found.

Após configurarmos o método returnFoundTask(), a classe ShowTask fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    @Nested
    inner class ShowTask {

        @Nested
        inner class WhenTaskIsFound {

            @BeforeEach
            fun returnFoundTask() {
                val found = TaskDTOBuilder()
                    .withId(1L)
                    .withCreator(99L)
                    .withTitle("title")
                    .withDescription("description")
                    .withStatusOpen()
                    .build()

                every { crudService.findById(1L) } returns found
            }
        }
    }

Passo 3: Escrever os três testes unitários para showTask()

Terceiro, escrevemos três testes unitários garantindo que o método showTask() funciona como esperado quando enviamos uma requisição GET para "/task/1" (get("/task/{taskId}", 1L)):

  1. Verificamos que o código de status HTTP 200 OK é retornado.
  2. Garantimos que o método showTask() renderiza a view "task/view".
  3. Verificamos que o atributo "task" do Model contém todas as propriedades esperadas da tarefa encontrada.

O código-fonte completo da classe TaskCrudControllerTest com os testes de ShowTask 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
package com.testwithspring.starter.spring.web

import io.mockk.every
import io.mockk.mockk
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.hasProperty
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.context.support.StaticMessageSource
import org.springframework.test.web.servlet.MockMvc
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

@Tag("unitTest")
class TaskCrudControllerTest {

    private lateinit var crudService: TaskCrudService
    private lateinit var messageSource: StaticMessageSource
    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        crudService = mockk(relaxed = true)
        messageSource = StaticMessageSource()

        val controller = TaskCrudController(
            crudService = crudService,
            messageSource = messageSource
        )
        mockMvc = MockMvcBuilders.standaloneSetup(controller)
            .build()
    }

    @Nested
    inner class ShowTask {

        @Nested
        inner class WhenTaskIsFound {

            @BeforeEach
            fun returnFoundTask() {
                val found = TaskDTOBuilder()
                    .withId(1L)
                    .withCreator(99L)
                    .withTitle("title")
                    .withDescription("description")
                    .withStatusOpen()
                    .build()

                every { crudService.findById(1L) } returns found
            }

            @Test
            fun shouldReturnHttpStatusCodeOk() {
                mockMvc.perform(get("/task/{taskId}", 1L))
                    .andExpect(status().isOk)
            }

            @Test
            fun shouldRenderViewTaskView() {
                mockMvc.perform(get("/task/{taskId}", 1L))
                    .andExpect(view().name("task/view"))
            }

            @Test
            fun shouldShowFoundTask() {
                mockMvc.perform(get("/task/{taskId}", 1L))
                    .andExpect(
                        model().attribute(
                            "task",
                            allOf(
                                hasProperty("assigneeId", nullValue()),
                                hasProperty("closerId", nullValue()),
                                hasProperty("creatorId", `is`(99L)),
                                hasProperty("id", `is`(1L)),
                                hasProperty("title", `is`("title")),
                                hasProperty("description", `is`("description")),
                                hasProperty("status", `is`(TaskStatus.OPEN)),
                                hasProperty("resolution", nullValue())
                            )
                        )
                    )
            }
        }
    }
}

Resumo

Nesta aula, aprendemos três pontos fundamentais:

  • Criamos requisições HTTP (get("/"), get("/task/{taskId}", 1L)) utilizando os métodos fábrica estáticos da classe MockMvcRequestBuilders.
  • Escrevemos asserções para a resposta HTTP retornada (status().isOk, view().name(...), model().attribute(...)) utilizando os métodos fábrica estáticos da classe MockMvcResultMatchers.
  • Escrevemos asserções expressivas sobre coleções e objetos dentro do Model combinando matchers do Hamcrest (hasSize, contains, allOf, hasItem, hasProperty, is, nullValue).