Testes de Integração para API REST: Leitura 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 @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:


1. Caso de Uso 1: Retornando 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 @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:

  1. 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.
  2. 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:

  1. Configurar o dataset @DatabaseSetup("/com/testwithspring/intermediate/tasks.xml") na classe.
  2. 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():

  1. Primeiro, verificar que retorna o código de status HTTP 200 OK (status().isOk).
  2. Segundo, garantir que retorna a tarefa encontrada com Content-Type JSON (content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)).
  3. 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).
  4. Quarto, garantir que retorna uma tarefa contendo exatamente uma tag (jsonPath("$.tags", hasSize<Any>(1))).
  5. 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:

  1. Obter o termo de busca a partir do parâmetro de query searchTerm.
  2. Buscar os resultados da pesquisa no banco de dados usando o TaskSearchService.
  3. 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.


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:

  1. Primeiro, garantir que retorna o código de status HTTP 200 OK.
  2. Segundo, verificar que retorna os resultados da busca como JSON (MediaType.APPLICATION_JSON).
  3. Terceiro, garantir que o array JSON raiz retorna exatamente uma tarefa (jsonPath("$", hasSize<Any>(1))).
  4. 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.