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
@RestController que consultam informações no banco de dados.
- Saber como formatar constantes de timestamp ISO-8601 com offset e fuso horário dinâmico do sistema para asserções JSON.
- Escrever testes de integração completos usando
MockMvc e jsonPath para endpoints REST que retornam objetos únicos e coleções em JSON.
Esta lição assume que:
Introdução ao Sistema Sob Teste (SUT)
Precisamos escrever testes de integração para um método de @RestController que processa requisições HTTP GET enviadas para o caminho /api/task/{taskId}. O nome desse método é findById(), e ele simplesmente delega a busca ao TaskCrudService e retorna o TaskDTO serializado como JSON.
O código-fonte do controller REST 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
|
package com.testwithspring.intermediate.web
import com.testwithspring.intermediate.task.TaskCrudService
import com.testwithspring.intermediate.task.TaskDTO
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/task")
class TaskCrudController(
private val crudService: TaskCrudService
) {
@GetMapping("/{taskId}")
fun findById(@PathVariable("taskId") taskId: Long): TaskDTO {
return crudService.findById(taskId)
}
}
|
Se uma tarefa concluída (CLOSED) for encontrada no banco de dados, o método findById() retornará o seguinte documento JSON para o cliente:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
{
"assigneeId": 1,
"closerId": 1,
"creationTime": "2016-12-03T11:41:28+02:00[Europe/Helsinki]",
"creatorId": 1,
"description": "This example contains integration tests",
"id": 1,
"modificationTime": "2016-12-03T11:41:28+02:00[Europe/Helsinki]",
"resolution": "DONE",
"status": "CLOSED",
"tags": [
{
"id": 1,
"name": "example"
}
],
"title": "Write example application"
}
|
Vamos ver como inicializar nosso banco de dados em um estado conhecido antes da execução dos nossos testes de integração.
Inicializando o Banco de Dados em um Estado Conhecido
Inicializaremos nosso banco de dados reutilizando exatamente o mesmo dataset tasks.xml que criamos na lição de Testes de Integração Web: Renderização de Dados:
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>
|
Como a nossa API REST serializa os campos creationTime e modificationTime como strings de timestamp ISO-8601 no JSON, precisamos fazer duas adaptações no objeto de constantes Tasks:
- Criar um método
getDateTimeWithZoneOffsetAndId(dateTime: String) que extrai o offset e o ID do fuso horário padrão do sistema operacional e os concatena à string de data/hora recebida por parâmetro. Isso garante que nossos testes de integração da API REST passem em qualquer fuso horário sem alterações nos dados de teste.
- Declarar as constantes
CREATION_TIME_STRING e MODIFICATION_TIME_STRING nas tarefas utilizando esse método utilitário.
Após atualizarmos o 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
|
package com.testwithspring.intermediate
import com.testwithspring.intermediate.task.TaskResolution
import com.testwithspring.intermediate.task.TaskStatus
import java.time.ZoneId
import java.time.ZonedDateTime
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_STRING: String = getDateTimeWithZoneOffsetAndId("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_STRING: String = getDateTimeWithZoneOffsetAndId("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_STRING: String = getDateTimeWithZoneOffsetAndId("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_STRING: String = getDateTimeWithZoneOffsetAndId("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 getDateTimeWithZoneOffsetAndId(dateTime: String): String {
return dateTime + getSystemZoneOffset() + getSystemZoneId()
}
private fun getSystemZoneOffset(): String =
ZonedDateTime.now().offset.toString()
private fun getSystemZoneId(): String =
"[${ZoneId.systemDefault()}]"
}
|
Com o dataset e as constantes prontos, vamos escrever os testes de integração para o método findById().
Escrevendo os Testes de Integração para findById()
Configuramos nossa classe de teste FindTaskWhenTaskIsFoundTest seguindo estes dois passos:
- Configurar o dataset
@DatabaseSetup("/com/testwithspring/intermediate/tasks.xml") na classe.
- Criar um método privado auxiliar
findTask() que envia uma requisição GET para /api/task/1 (Tasks.WriteExampleApp.ID) e retorna o ResultActions.
Após configurarmos a classe, escrevemos cinco testes de integração para o método findById():
- Primeiro, verificar que retorna o código de status HTTP
200 OK (status().isOk).
- Segundo, garantir que retorna a tarefa encontrada com
Content-Type JSON (content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)).
- Terceiro, verificar que retorna todas as propriedades escalares da tarefa encontrada avaliando as expressões
jsonPath("$.campo", equalTo(...)). (Nota: como o Jayway JsonPath desserializa números pequenos do JSON como Integer, convertemos IDs Long com .toInt() na comparação Hamcrest).
- Quarto, garantir que retorna uma tarefa contendo exatamente uma tag (
jsonPath("$.tags", hasSize<Any>(1))).
- Quinto, verificar que retorna o
id e o name da tag correta ($.tags[0].id e $.tags[0].name).
O código completo da classe FindTaskWhenTaskIsFoundTest com todos os 5 testes é 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
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
|
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.equalTo
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.http.MediaType
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.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
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 FindTaskWhenTaskIsFoundTest {
@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`() {
findTask()
.andExpect(status().isOk)
}
@Test
fun `deve retornar a tarefa encontrada como JSON`() {
findTask()
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
}
@Test
fun `deve retornar as informacoes da tarefa encontrada`() {
findTask()
.andExpect(jsonPath("$.assigneeId", equalTo(Tasks.WriteExampleApp.ASSIGNEE_ID.toInt())))
.andExpect(jsonPath("$.closerId", equalTo(Tasks.WriteExampleApp.CLOSER_ID.toInt())))
.andExpect(jsonPath("$.creationTime", equalTo(Tasks.WriteExampleApp.CREATION_TIME_STRING)))
.andExpect(jsonPath("$.creatorId", equalTo(Tasks.WriteExampleApp.CREATOR_ID.toInt())))
.andExpect(jsonPath("$.id", equalTo(Tasks.WriteExampleApp.ID.toInt())))
.andExpect(jsonPath("$.modificationTime", equalTo(Tasks.WriteExampleApp.MODIFICATION_TIME_STRING)))
.andExpect(jsonPath("$.title", equalTo(Tasks.WriteExampleApp.TITLE)))
.andExpect(jsonPath("$.description", equalTo(Tasks.WriteExampleApp.DESCRIPTION)))
.andExpect(jsonPath("$.status", equalTo(Tasks.WriteExampleApp.STATUS.toString())))
.andExpect(jsonPath("$.resolution", equalTo(Tasks.WriteExampleApp.RESOLUTION.toString())))
}
@Test
fun `deve retornar tarefa que possui uma tag`() {
findTask()
.andExpect(jsonPath("$.tags", hasSize<Any>(1)))
}
@Test
fun `deve retornar as informacoes da tag correta`() {
findTask()
.andExpect(jsonPath("$.tags[0].id", equalTo(Tasks.WriteExampleApp.Tags.Example.ID.toInt())))
.andExpect(jsonPath("$.tags[0].name", equalTo(Tasks.WriteExampleApp.Tags.Example.NAME)))
}
private fun findTask(): ResultActions {
return mockMvc.perform(
get("/api/task/{taskId}", Tasks.WriteExampleApp.ID)
)
}
}
|
A seguir, escreveremos testes de integração para um endpoint REST que pesquisa tarefas no banco de dados.
2. Caso de Uso 2: Pesquisando Tarefas via API REST
Introdução ao Sistema Sob Teste (SUT)
Precisamos escrever testes de integração para um método de @RestController que processa requisições GET enviadas para o caminho /api/task/search. Esse método se chama search() e é implementado seguindo estes três passos:
- Obter o termo de busca a partir do parâmetro de query
searchTerm.
- Buscar os resultados da pesquisa no banco de dados usando o
TaskSearchService.
- Retornar a lista
List<TaskListDTO> serializada como um array JSON.
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
|
package com.testwithspring.intermediate.web
import com.testwithspring.intermediate.task.TaskListDTO
import com.testwithspring.intermediate.task.TaskSearchService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
class TaskSearchController(
private val service: TaskSearchService
) {
@GetMapping("/api/task/search")
fun search(@RequestParam("searchTerm") searchTerm: String): List<TaskListDTO> {
return service.search(searchTerm)
}
}
|
Se duas tarefas forem encontradas no banco de dados, o método search() retornará o seguinte array JSON para o cliente:
1
2
3
4
5
6
7
8
9
10
11
12
|
[
{
"id": 1,
"title": "Write example application",
"status": "CLOSED"
},
{
"id": 2,
"title": "Write lesson",
"status": "OPEN"
}
]
|
Vamos escrever os testes de integração que garantem que o método search() funcione conforme o esperado quando uma tarefa é encontrada.
Escrevendo os Testes de Integração para search()
Configuramos nossa classe de teste SearchTasksWhenOneTaskIsFoundTest reutilizando o mesmo dataset /com/testwithspring/intermediate/tasks.xml e criando o método auxiliar search() que envia um GET para /api/task/search?searchTerm=Esso.
Em seguida, implementamos quatro testes de integração:
- Primeiro, garantir que retorna o código de status HTTP
200 OK.
- Segundo, verificar que retorna os resultados da busca como JSON (
MediaType.APPLICATION_JSON).
- Terceiro, garantir que o array JSON raiz retorna exatamente uma tarefa (
jsonPath("$", hasSize<Any>(1))).
- Quarto, verificar que o primeiro elemento do array (
$[0]) contém o id, status e title da tarefa correta (Tasks.WriteLesson).
O código completo da classe SearchTasksWhenOneTaskIsFoundTest é 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
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
|
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.equalTo
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.http.MediaType
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.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
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 SearchTasksWhenOneTaskIsFoundTest {
@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`() {
search()
.andExpect(status().isOk)
}
@Test
fun `deve retornar os resultados da busca como JSON`() {
search()
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
}
@Test
fun `deve retornar uma tarefa`() {
search()
.andExpect(jsonPath("$", hasSize<Any>(1)))
}
@Test
fun `deve retornar as informacoes da tarefa encontrada`() {
search()
.andExpect(jsonPath("$[0].id", equalTo(Tasks.WriteLesson.ID.toInt())))
.andExpect(jsonPath("$[0].status", equalTo(Tasks.WriteLesson.STATUS.toString())))
.andExpect(jsonPath("$[0].title", equalTo(Tasks.WriteLesson.TITLE)))
}
private fun search(): ResultActions {
return mockMvc.perform(
get("/api/task/search")
.param("searchTerm", Tasks.SEARCH_TERM_ONE_MATCH)
)
}
}
|
Vamos resumir o que aprendemos nesta lição.
Resumo
Esta lição nos ensinou três pontos fundamentais:
- Ao escrever testes de integração para endpoints REST de consulta (finder methods), todos os métodos de teste podem compartilhar o mesmo dataset inicial (
tasks.xml).
- Podemos suportar múltiplos fusos horários em respostas JSON garantindo que as strings de data/hora esperadas incorporem dinamicamente o offset e o
ZoneId padrão do sistema.
- Reduzimos a duplicação de código escrevendo métodos auxiliares (
findTask(), search()) que encapsulam a construção da requisição MockMvc.