Testes de Integração Web: Renderização de Dados

Ao concluir esta lição, você será capaz de:

  • Compreender que tipo de massa de dados devemos usar ao escrever testes de integração para métodos de controller que buscam informações no banco de dados.
  • Saber como lidar com fusos horários (time zones) em constantes de teste para que a suíte passe em qualquer máquina ou servidor de CI.
  • Escrever testes de integração completos para métodos de controller que renderizam dados recuperados do banco de dados.

Esta lição assume que:


1. Caso de Uso 1: Renderizando as Informações de uma Única Tarefa

Introdução ao Sistema Sob Teste (SUT)

Precisamos escrever testes de integração para um método de controller que processa requisições HTTP GET enviadas para o caminho /task/{taskId}. Esse método se chama showTask() e é implementado seguindo estes três passos:

  1. Buscar a tarefa no banco de dados através do TaskCrudService.
  2. Adicionar a tarefa encontrada em um atributo do Model chamado "task".
  3. Retornar o nome lógico da view ("task/view") que renderiza os detalhes da tarefa encontrada.

O código-fonte do controller testado em Kotlin é 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
package com.testwithspring.intermediate.web

import com.testwithspring.intermediate.task.TaskCrudService
import com.testwithspring.intermediate.task.TaskDTO
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. Seu código-fonte em Kotlin é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package com.testwithspring.intermediate.task

import java.time.ZonedDateTime

data class TaskDTO(
    val id: Long,
    val title: String,
    val description: String,
    val status: TaskStatus,
    val resolution: TaskResolution? = null,
    val creatorId: Long,
    val assigneeId: Long? = null,
    val closerId: Long? = null,
    val creationTime: ZonedDateTime,
    val modificationTime: ZonedDateTime,
    val tags: List<TagDTO> = emptyList()
)

A classe TagDTO contém as informações de uma única tag associada à tarefa:

1
2
3
4
5
6
package com.testwithspring.intermediate.task

data class TagDTO(
    val id: Long,
    val name: String
)

A seguir, escreveremos os testes de integração que garantem que o método showTask() funcione conforme o esperado quando a tarefa solicitada é encontrada no banco de dados. Começaremos preparando o estado conhecido do banco.


Inicializando o Banco de Dados em um Estado Conhecido

Para inicializar o banco de dados em um estado conhecido, reutilizaremos o mesmo dataset tasks.xml que criamos na lição anterior. Reutilizar datasets de leitura é uma excelente prática porque reduz drasticamente o esforço de manutenção quando o esquema do banco evolui.

Contudo, como o método showTask() do controller também exibe as informações das tags associadas à tarefa encontradas no banco de dados, precisamos adicionar duas linhas na tabela tags e vincular essas tags às tarefas através da tabela de junção tasks_tags.

Após adicionarmos essas linhas ao nosso dataset tasks.xml, seu código-fonte ficará 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
<dataset>
    <tasks id="1"
           assignee_id="1"
           closer_id="1"
           creation_time="2016-12-3 11:41:28"
           creator_id="1"
           description="This example contains integration tests"
           modification_time="2016-12-3 11:41:28"
           resolution="DONE"
           status="CLOSED"
           title="Write example application"
           version="0"/>
    <tasks id="2"
           assignee_id="[null]"
           closer_id="[null]"
           creation_time="2016-12-4 11:41:28"
           creator_id="1"
           description="This lesson talks about integration testing"
           modification_time="2016-12-4 11:41:28"
           resolution="[null]"
           status="OPEN"
           title="Write lesson"
           version="0"/>

    <tags id="1"
          creation_time="2016-12-3 11:41:28"
          modification_time="2016-12-3 11:41:28"
          name="example"
          version="0"/>
    <tags id="2"
          creation_time="2016-12-3 11:41:28"
          modification_time="2016-12-3 11:41:28"
          name="lesson"
          version="0"/>

    <tasks_tags task_id="1" tag_id="1"/>
    <tasks_tags task_id="2" tag_id="2"/>
</dataset>

Após atualizarmos o nosso dataset, precisamos realizar três adições na classe de constantes Tasks que espelha os dados do banco:

  1. Criar um método utilitário parseDateTime() que transforma uma string de data/hora local em um objeto ZonedDateTime usando o fuso horário padrão do sistema operacional (ZoneId.systemDefault()). Isso garante que possamos rodar nossos testes de integração em máquinas de desenvolvedores ou pipelines de CI em diferentes fusos horários (ex: America/Sao_Paulo, UTC, Europe/Helsinki) sem que as asserções de timestamp falhem.
  2. Declarar todas as constantes com os valores das colunas das tarefas presentes na tabela tasks, utilizando o método parseDateTime() para CREATION_TIME e MODIFICATION_TIME.
  3. Declarar as constantes aninhadas com os valores das colunas das tags (Tags.Example e Tags.Lesson).

Após adicionarmos essas constantes ao objeto Tasks, seu código-fonte ficará 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
package com.testwithspring.intermediate

import com.testwithspring.intermediate.task.TaskResolution
import com.testwithspring.intermediate.task.TaskStatus
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter

object Tasks {

    const val SEARCH_TERM_ONE_MATCH = "Esso"
    const val SEARCH_TERM_NOT_FOUND = "NO_PE"

    object WriteExampleApp {
        const val ASSIGNEE_ID = 1L
        const val CLOSER_ID = 1L
        val CREATION_TIME: ZonedDateTime = parseDateTime("2016-12-03T11:41:28")
        const val CREATOR_ID = 1L
        const val DESCRIPTION = "This example contains integration tests"
        const val ID = 1L
        val MODIFICATION_TIME: ZonedDateTime = parseDateTime("2016-12-03T11:41:28")
        val RESOLUTION = TaskResolution.DONE
        val STATUS = TaskStatus.CLOSED
        const val TITLE = "Write example application"

        object Tags {
            object Example {
                const val ID = 1L
                const val NAME = "example"
            }
        }
    }

    object WriteLesson {
        val CREATION_TIME: ZonedDateTime = parseDateTime("2016-12-04T11:41:28")
        const val CREATOR_ID = 1L
        const val DESCRIPTION = "This lesson talks about integration testing"
        const val ID = 2L
        val MODIFICATION_TIME: ZonedDateTime = parseDateTime("2016-12-04T11:41:28")
        val STATUS = TaskStatus.OPEN
        const val TITLE = "Write lesson"

        object Tags {
            object Lesson {
                const val ID = 2L
                const val NAME = "lesson"
            }
        }
    }

    private fun parseDateTime(dateTime: String): ZonedDateTime {
        val fullDateTime = dateTime + getSystemZoneOffset() + getSystemZoneId()
        return ZonedDateTime.from(DateTimeFormatter.ISO_ZONED_DATE_TIME.parse(fullDateTime))
    }

    private fun getSystemZoneOffset(): String =
        ZonedDateTime.now().offset.toString()

    private fun getSystemZoneId(): String =
        "[${ZoneId.systemDefault()}]"
}

Com o dataset e as constantes prontos, podemos escrever os testes de integração para o método showTask().


Escrevendo os Testes de Integração para showTask()

Antes de escrevermos os métodos @Test, configuramos nossa classe de teste ShowTaskWhenTaskIsFoundTest seguindo estes dois passos:

  1. Configurar o dataset /com/testwithspring/intermediate/tasks.xml na classe com @DatabaseSetup. Como estamos testando um método que apenas consulta dados, todos os testes podem compartilhar esse mesmo dataset.
  2. Criar um método privado auxiliar openShowTaskPage() que envia uma requisição GET para /task/1 e retorna um objeto ResultActions. O objetivo desse método helper é eliminar a duplicação de código típica de classes de teste que utilizam MockMvc.

Após configurarmos a estrutura base da nossa classe de teste, seu código ficará 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
package com.testwithspring.intermediate.web

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.config.Profiles
import org.junit.jupiter.api.BeforeEach
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext

@SpringBootTest
@AutoConfigureMockMvc
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class,
        ServletTestExecutionListener::class
    ]
)
@DatabaseSetup("/com/testwithspring/intermediate/tasks.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class ShowTaskWhenTaskIsFoundTest {

    @Autowired
    private lateinit var webAppContext: WebApplicationContext

    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
            .build()
    }

    private fun openShowTaskPage(): ResultActions {
        return mockMvc.perform(
            get("/task/{taskId}", Tasks.WriteExampleApp.ID)
        )
    }
}

Agora escreveremos seis testes de integração para garantir que o método showTask() funcione conforme o esperado quando a tarefa solicitada existe no banco de dados.

Primeiro, verificamos que o método do controller retorna o código de status HTTP 200 OK:

1
2
3
4
5
    @Test
    fun `deve retornar status HTTP 200 OK`() {
        openShowTaskPage()
            .andExpect(status().isOk)
    }

Segundo, garantimos que o método do controller renderiza a view "task/view":

1
2
3
4
5
    @Test
    fun `deve renderizar a view de visualizacao de tarefa`() {
        openShowTaskPage()
            .andExpect(view().name("task/view"))
    }

Terceiro, verificamos que o método do controller encaminha (forwards) o usuário para a URL física correta da página (quando usamos InternalResourceViewResolver / JSP):

1
2
3
4
5
    @Test
    fun `deve encaminhar o usuario para a URL da pagina de visualizacao`() {
        openShowTaskPage()
            .andExpect(forwardedUrl("/WEB-INF/jsp/task/view.jsp"))
    }

Nota sobre Engines de Template Modernas (Thymeleaf, JTE, Mustache):

Se não estivermos utilizando views JSP (e sim motores de template modernos no Spring Boot 4 como Thymeleaf ou JTE), não precisamos escrever o teste de forwardedUrl, pois essas engines leem o arquivo de template diretamente do classpath e escrevem o HTML renderizado no corpo da resposta (response.body) sem realizar um RequestDispatcher.forward() no container Servlet.

Quarto, verificamos que o método do controller disponibiliza no atributo "task" do Model todas as propriedades escalares da tarefa encontrada no banco de dados:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
    @Test
    fun `deve exibir os dados da tarefa encontrada`() {
        openShowTaskPage()
            .andExpect(
                model().attribute(
                    "task",
                    allOf(
                        hasProperty("assigneeId", equalTo(Tasks.WriteExampleApp.ASSIGNEE_ID)),
                        hasProperty("closerId", equalTo(Tasks.WriteExampleApp.CLOSER_ID)),
                        hasProperty("creationTime", equalTo(Tasks.WriteExampleApp.CREATION_TIME)),
                        hasProperty("creatorId", equalTo(Tasks.WriteExampleApp.CREATOR_ID)),
                        hasProperty("id", equalTo(Tasks.WriteExampleApp.ID)),
                        hasProperty("modificationTime", equalTo(Tasks.WriteExampleApp.MODIFICATION_TIME)),
                        hasProperty("title", equalTo(Tasks.WriteExampleApp.TITLE)),
                        hasProperty("description", equalTo(Tasks.WriteExampleApp.DESCRIPTION)),
                        hasProperty("status", equalTo(Tasks.WriteExampleApp.STATUS)),
                        hasProperty("resolution", equalTo(Tasks.WriteExampleApp.RESOLUTION))
                    )
                )
            )
    }

Quinto, garantimos que a tarefa exibida no Model possui exatamente uma tag associada (hasSize(1)):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    @Test
    fun `deve exibir uma tag para a tarefa encontrada`() {
        openShowTaskPage()
            .andExpect(
                model().attribute(
                    "task",
                    hasProperty("tags", hasSize<Any>(1))
                )
            )
    }

Sexto, verificamos que o método do controller exibe o id e o name corretos da tag encontrada no banco de dados:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
    @Test
    fun `deve exibir os dados corretos da tag encontrada`() {
        openShowTaskPage()
            .andExpect(
                model().attribute(
                    "task",
                    hasProperty(
                        "tags",
                        contains(
                            allOf(
                                hasProperty("id", equalTo(Tasks.WriteExampleApp.Tags.Example.ID)),
                                hasProperty("name", equalTo(Tasks.WriteExampleApp.Tags.Example.NAME))
                            )
                        )
                    )
                )
            )
    }

A seguir, escreveremos testes de integração para um método de controller que pesquisa tarefas no banco de dados.


2. Caso de Uso 2: Pesquisando Tarefas

Introdução ao Sistema Sob Teste (SUT)

Precisamos escrever testes de integração para um método de controller que processa requisições POST enviadas para o caminho /task/search. Esse método se chama showSearchResults() e é implementado seguindo estes quatro passos:

  1. Obter o termo de busca a partir do parâmetro de requisição chamado searchTerm.
  2. Buscar os resultados da pesquisa no banco de dados através do TaskSearchService.
  3. Colocar a lista de resultados em um atributo do Model chamado "tasks".
  4. Retornar o nome da view ("task/search-results") que renderiza os resultados da busca.

O código-fonte do controller testado em Kotlin é 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.intermediate.web

import com.testwithspring.intermediate.task.TaskListDTO
import com.testwithspring.intermediate.task.TaskSearchService
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam

@Controller
class TaskSearchController(
    private val service: TaskSearchService
) {

    @PostMapping("/task/search")
    fun showSearchResults(
        @RequestParam("searchTerm") searchTerm: String,
        model: Model
    ): String {
        val searchResults: List<TaskListDTO> = service.search(searchTerm)
        model.addAttribute("tasks", searchResults)
        return "task/search-results"
    }
}

O TaskListDTO é a classe DTO que contém o resumo de uma única tarefa exibida na página de resultados:

1
2
3
4
5
6
7
package com.testwithspring.intermediate.task

data class TaskListDTO(
    val id: Long,
    val title: String,
    val status: TaskStatus
)

Vamos escrever os testes de integração que garantem que o método showSearchResults() funcione conforme o esperado quando uma tarefa é encontrada.


Escrevendo os Testes de Integração para showSearchResults()

Primeiro, configuramos nossa classe de teste SearchWhenOneTaskIsFoundTest seguindo estes dois passos:

  1. Configurar o dataset /com/testwithspring/intermediate/tasks.xml com @DatabaseSetup (reutilizando exatamente o mesmo arquivo XML que usamos nos testes de showTask()).
  2. Criar um método privado submitSearchForm() que envia uma requisição POST para /task/search passando o parâmetro searchTerm = Tasks.SEARCH_TERM_ONE_MATCH e retorna um ResultActions.

Após configurarmos a classe e implementarmos os cinco testes de integração (status 200 OK, nome da view, URL encaminhada, tamanho da lista de resultados e atributos da tarefa encontrada), o código completo da classe SearchWhenOneTaskIsFoundTest ficará 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
package com.testwithspring.intermediate.web

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.config.Profiles
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.hasItem
import org.hamcrest.Matchers.hasProperty
import org.hamcrest.Matchers.hasSize
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl
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 org.springframework.web.context.WebApplicationContext

@SpringBootTest
@AutoConfigureMockMvc
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class,
        ServletTestExecutionListener::class
    ]
)
@DatabaseSetup("/com/testwithspring/intermediate/tasks.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class SearchWhenOneTaskIsFoundTest {

    @Autowired
    private lateinit var webAppContext: WebApplicationContext

    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
            .build()
    }

    @Test
    fun `deve retornar status HTTP 200 OK`() {
        submitSearchForm()
            .andExpect(status().isOk)
    }

    @Test
    fun `deve renderizar a view de resultados de busca`() {
        submitSearchForm()
            .andExpect(view().name("task/search-results"))
    }

    @Test
    fun `deve encaminhar o usuario para a URL da pagina de resultados`() {
        submitSearchForm()
            .andExpect(forwardedUrl("/WEB-INF/jsp/task/search-results.jsp"))
    }

    @Test
    fun `deve exibir uma tarefa na lista de resultados`() {
        submitSearchForm()
            .andExpect(model().attribute("tasks", hasSize<Any>(1)))
    }

    @Test
    fun `deve exibir as informacoes da tarefa correta`() {
        submitSearchForm()
            .andExpect(
                model().attribute(
                    "tasks",
                    hasItem(
                        allOf(
                            hasProperty("id", equalTo(Tasks.WriteLesson.ID)),
                            hasProperty("title", equalTo(Tasks.WriteLesson.TITLE)),
                            hasProperty("status", equalTo(Tasks.WriteLesson.STATUS))
                        )
                    )
                )
            )
    }

    private fun submitSearchForm(): ResultActions {
        return mockMvc.perform(
            post("/task/search")
                .param("searchTerm", Tasks.SEARCH_TERM_ONE_MATCH)
        )
    }
}

Escrevemos testes de integração completos para dois métodos distintos de controller que buscam e renderizam informações do banco de dados. Vamos resumir o que aprendemos nesta lição.


Resumo

Esta lição nos ensinou três pontos essenciais:

  • É uma excelente prática reutilizar o mesmo dataset (tasks.xml) ao escrever testes de integração para controllers e repositórios que apenas consultam informações no banco de dados.
  • Podemos suportar múltiplos fusos horários garantindo que as constantes de data/hora criadas para os testes utilizem dinamicamente o fuso horário padrão do sistema (ZoneId.systemDefault()).
  • Reduzimos drasticamente a duplicação de código escrevendo métodos auxiliares privados (openShowTaskPage(), submitSearchForm()) que montam e enviam a requisição HTTP ao controller testado.