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:
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:
- Buscar a tarefa no banco de dados através do
TaskCrudService.
- Adicionar a tarefa encontrada em um atributo do
Model chamado "task".
- 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:
- 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.
- 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.
- 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:
- 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.
- 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:
- Obter o termo de busca a partir do parâmetro de requisição chamado
searchTerm.
- Buscar os resultados da pesquisa no banco de dados através do
TaskSearchService.
- Colocar a lista de resultados em um atributo do
Model chamado "tasks".
- 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:
- Configurar o dataset
/com/testwithspring/intermediate/tasks.xml com @DatabaseSetup (reutilizando exatamente o mesmo arquivo XML que usamos nos testes de showTask()).
- 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.