Testes de Integração para API REST: Escrita 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 endpoints de @RestController que gravam dados no banco de dados.
  • Escrever asserções JSON resilientes para múltiplos erros de validação (independentemente da ordem em que o Bean Validation os retorne).
  • Escrever testes de integração completos para endpoints REST que atualizam ou inserem dados no banco de dados, validando tanto o payload JSON de resposta quanto o estado persistido nas tabelas.

Esta lição assume que:


1. Introdução ao Sistema Sob Teste (SUT)

Precisamos escrever testes de integração para um método de @RestController que processa requisições HTTP PUT enviadas para o caminho /api/task/{taskId}. Esse método se chama update(), e é implementado seguindo estes quatro passos:

  1. Ler as informações da tarefa atualizada a partir do corpo da requisição JSON (@RequestBody) e validar suas restrições (@Valid).
  2. Se forem encontrados erros de validação, retornar o código de status HTTP 400 Bad Request e descrever os erros de campo no corpo da resposta JSON (via @RestControllerAdvice / TaskErrorHandler).
  3. Definir o id da tarefa a ser atualizada com base na variável de caminho taskId.
  4. Atualizar as informações da tarefa no banco de dados e retornar o TaskDTO atualizado como JSON.

O código-fonte do método 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
25
26
27
package com.testwithspring.intermediate.web

import com.testwithspring.intermediate.task.TaskCrudService
import com.testwithspring.intermediate.task.TaskDTO
import com.testwithspring.intermediate.task.TaskFormDTO
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/task")
class TaskCrudController(
    private val crudService: TaskCrudService
) {

    @PutMapping("/{taskId}")
    fun update(
        @PathVariable("taskId") taskId: Long,
        @Valid @RequestBody task: TaskFormDTO
    ): TaskDTO {
        task.id = taskId
        return crudService.update(task)
    }
}

A classe TaskFormDTO contém os dados de entrada e as regras de validação do Jakarta Bean Validation:

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

import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size

class TaskFormDTO(
    var id: Long? = null,

    @field:Size(max = 500)
    var description: String? = null,

    @field:NotBlank
    @field:Size(max = 100)
    var title: String? = null
)

Os erros de validação são processados pela classe TaskErrorHandler (@ControllerAdvice). Se o cliente tentar atualizar uma tarefa enviando um título em branco (""), o seguinte documento JSON será retornado com status 400 Bad Request:

1
2
3
4
5
6
7
8
{
    "fieldErrors": [
        {
            "field": "title",
            "errorCode": "NotBlank"
        }
    ]
}

Por outro lado, se as informações de uma tarefa aberta forem atualizadas com sucesso, o seguinte documento JSON será retornado ao cliente com status 200 OK:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
    "assigneeId": null,
    "closerId": null,
    "creationTime": "2016-12-04T11:41:28+02:00[Europe/Helsinki]",
    "creatorId": 1,
    "description": "The old lesson was not good",
    "id": 2,
    "modificationTime": "2016-12-03T21:14:28+02:00[Europe/Helsinki]",
    "resolution": null,
    "status": "OPEN",
    "tags": [
        {
            "id": 2,
            "name": "lesson"
        }
    ],
    "title": "Rewrite an existing lesson"
}

Vamos criar o dataset do DbUnit que inicializa nosso banco de dados em um estado conhecido antes dos testes.


2. Inicializando o Banco de Dados em um Estado Conhecido

Ao escrever testes de integração para endpoints REST que modificam o banco de dados, seguimos exatamente os mesmos princípios estabelecidos na lição de formulários web:

Como estamos testando um método que atualiza uma tarefa existente, criamos um dataset chamado task.xml que insere uma única linha nas tabelas tasks, tags e tasks_tags:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<dataset>
    <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="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="2" tag_id="2"/>
</dataset>

A seguir, escreveremos os testes de integração para os cenários de falha de validação e de sucesso.


3. Escrevendo os Testes de Integração

Cenário 1: A Validação Falha

Configuramos nossa classe de teste UpdateTaskWhenValidationFailsTest seguindo estes dois passos:

  1. Configurar o dataset @DatabaseSetup("task.xml") na classe.
  2. Criar um método privado updateTaskWithEmptyData() que instancia um TaskFormDTO com title = "" e description = "", converte-o para bytes JSON usando WebTestUtil.convertObjectToJsonBytes(input) e envia uma requisição PUT para /api/task/2.

Em seguida, escrevemos cinco testes de integração:

  1. Primeiro, verificar que retorna o código de status HTTP 400 Bad Request (status().isBadRequest).
  2. Segundo, garantir que retorna os erros de validação como JSON (content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)).
  3. Terceiro, verificar que retorna exatamente um erro de validação (jsonPath("$.fieldErrors", hasSize<Any>(1))).
  4. Quarto, garantir que retorna o erro de validação sobre o campo title vazio (field = "title", errorCode = "NotBlank").
  5. Quinto, verificar que não atualiza a tarefa no banco de dados (@ExpectedDatabase(value = "task.xml", assertionMode = DatabaseAssertionMode.NON_STRICT)).

O código completo da classe UpdateTaskWhenValidationFailsTest é 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
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.github.springtestdbunit.annotation.ExpectedDatabase
import com.github.springtestdbunit.assertion.DatabaseAssertionMode
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 com.testwithspring.intermediate.task.TaskFormDTO
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.put
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("task.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class UpdateTaskWhenValidationFailsTest {

    @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 400 Bad Request`() {
        updateTaskWithEmptyData()
            .andExpect(status().isBadRequest)
    }

    @Test
    fun `deve retornar erros de validacao como JSON`() {
        updateTaskWithEmptyData()
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
    }

    @Test
    fun `deve retornar um erro de validacao`() {
        updateTaskWithEmptyData()
            .andExpect(jsonPath("$.fieldErrors", hasSize<Any>(1)))
    }

    @Test
    fun `deve retornar erro de validacao sobre titulo vazio`() {
        updateTaskWithEmptyData()
            .andExpect(jsonPath("$.fieldErrors[0].field", equalTo("title")))
            .andExpect(jsonPath("$.fieldErrors[0].errorCode", equalTo("NotBlank")))
    }

    @Test
    @ExpectedDatabase(
        value = "task.xml",
        assertionMode = DatabaseAssertionMode.NON_STRICT
    )
    fun `nao deve atualizar a tarefa no banco de dados`() {
        updateTaskWithEmptyData()
    }

    private fun updateTaskWithEmptyData(): ResultActions {
        val input = TaskFormDTO(
            id = Tasks.WriteLesson.ID,
            title = "",
            description = ""
        )

        return mockMvc.perform(
            put("/api/task/{taskId}", Tasks.WriteLesson.ID)
                .contentType(MediaType.APPLICATION_JSON)
                .content(WebTestUtil.convertObjectToJsonBytes(input))
        )
    }
}

Atenção ao Testar Múltiplos Erros de Validação:

Se você precisar escrever um teste de integração que verifique o retorno de múltiplos erros de validação simultâneos (por exemplo, quando tanto title quanto description excedem o tamanho máximo), você não pode usar índices fixos como $.fieldErrors[0], pois o Jakarta Bean Validation não garante a ordem em que as violações são retornadas. Em vez disso, utilize filtros de predicado do JsonPath:

1
2
3
updateTaskWithTooLongStrings()
    .andExpect(jsonPath("$.fieldErrors[?(@.field == 'title')].errorCode", contains("Size")))
    .andExpect(jsonPath("$.fieldErrors[?(@.field == 'description')].errorCode", contains("Size")))

Cenário 2: A Validação é Bem-Sucedida

Para testar a atualização bem-sucedida, configuramos nossa classe UpdateTaskWhenValidationIsSuccessfulTest seguindo estes três passos:

  1. Configurar o dataset @DatabaseSetup("task.xml") na classe.
  2. Declarar as constantes NEW_DESCRIPTION e NEW_TITLE.
  3. Criar o método auxiliar privado updateTask() que envia uma requisição PUT para /api/task/2 com o JSON contendo o novo título e a nova descrição.

Lembrete sobre Inserção (POST): Se estivermos escrevendo testes de integração para um endpoint REST que insere novos registros no banco de dados (POST /api/task), devemos resetar os valores gerados da coluna id no método @BeforeEach antes de cada teste!

Em seguida, escrevemos dez testes de integração (5 validando a resposta HTTP/JSON retornada ao cliente e 5 validando o estado persistido no banco de dados com datasets focados):

  1. Primeiro, verificar que retorna o código de status HTTP 200 OK.
  2. Segundo, garantir que retorna a tarefa atualizada como JSON (MediaType.APPLICATION_JSON).
  3. Terceiro, verificar que retorna no JSON todas as propriedades escalares atualizadas da tarefa (incluindo modificationTime igual a ConstantDateTimeService.CURRENT_DATE_AND_TIME, title = NEW_TITLE e description = NEW_DESCRIPTION).
  4. Quarto, garantir que o JSON retornado possui exatamente uma tag ($.tags com hasSize(1)).
  5. Quinto, verificar que o JSON retornado contém os dados da tag correta (Tasks.WriteLesson.Tags.Lesson).
  6. Sexto, garantir que atualizou o title e a description no banco de dados (update-task-should-update-title-and-description.xml).
  7. Sétimo, verificar que atualizou modification_time e version no banco de dados (update-task-should-update-lifecycle-fields.xml).
  8. Oitavo, garantir que não alterou o status, closer_id ou resolution no banco (update-task-should-not-change-status.xml).
  9. Nono, verificar que não alterou o assignee_id no banco (update-task-should-not-change-assignee.xml).
  10. Décimo, garantir que não alterou as tags da tarefa no banco (update-task-should-not-change-tags.xml).

O código completo da classe UpdateTaskWhenValidationIsSuccessfulTest com todos os 10 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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
package com.testwithspring.intermediate.web

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.github.springtestdbunit.annotation.ExpectedDatabase
import com.github.springtestdbunit.assertion.DatabaseAssertionMode
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.Tasks
import com.testwithspring.intermediate.common.ConstantDateTimeService
import com.testwithspring.intermediate.config.Profiles
import com.testwithspring.intermediate.task.TaskFormDTO
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.nullValue
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.put
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("task.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class UpdateTaskWhenValidationIsSuccessfulTest {

    companion object {
        private const val NEW_DESCRIPTION = "The old lesson was not good"
        private const val NEW_TITLE = "Rewrite an existing lesson"
    }

    @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`() {
        updateTask()
            .andExpect(status().isOk)
    }

    @Test
    fun `deve retornar a tarefa atualizada como JSON`() {
        updateTask()
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
    }

    @Test
    fun `deve retornar as informacoes da tarefa atualizada`() {
        updateTask()
            .andExpect(jsonPath("$.assigneeId", nullValue()))
            .andExpect(jsonPath("$.closerId", nullValue()))
            .andExpect(jsonPath("$.creationTime", equalTo(Tasks.WriteLesson.CREATION_TIME_STRING)))
            .andExpect(jsonPath("$.creatorId", equalTo(Tasks.WriteLesson.CREATOR_ID.toInt())))
            .andExpect(jsonPath("$.id", equalTo(Tasks.WriteLesson.ID.toInt())))
            .andExpect(jsonPath("$.modificationTime", equalTo(ConstantDateTimeService.CURRENT_DATE_AND_TIME)))
            .andExpect(jsonPath("$.title", equalTo(NEW_TITLE)))
            .andExpect(jsonPath("$.description", equalTo(NEW_DESCRIPTION)))
            .andExpect(jsonPath("$.status", equalTo(Tasks.WriteLesson.STATUS.toString())))
            .andExpect(jsonPath("$.resolution", nullValue()))
    }

    @Test
    fun `deve retornar tarefa que possui uma tag`() {
        updateTask()
            .andExpect(jsonPath("$.tags", hasSize<Any>(1)))
    }

    @Test
    fun `deve retornar as informacoes da tag correta`() {
        updateTask()
            .andExpect(jsonPath("$.tags[0].id", equalTo(Tasks.WriteLesson.Tags.Lesson.ID.toInt())))
            .andExpect(jsonPath("$.tags[0].name", equalTo(Tasks.WriteLesson.Tags.Lesson.NAME)))
    }

    @Test
    @ExpectedDatabase(
        value = "update-task-should-update-title-and-description.xml",
        assertionMode = DatabaseAssertionMode.NON_STRICT
    )
    fun `deve atualizar o titulo e a descricao no banco de dados`() {
        updateTask()
    }

    @Test
    @ExpectedDatabase(
        value = "update-task-should-update-lifecycle-fields.xml",
        assertionMode = DatabaseAssertionMode.NON_STRICT
    )
    fun `deve atualizar modificationTime e version no banco de dados`() {
        updateTask()
    }

    @Test
    @ExpectedDatabase(
        value = "update-task-should-not-change-status.xml",
        assertionMode = DatabaseAssertionMode.NON_STRICT
    )
    fun `nao deve alterar o status da tarefa no banco de dados`() {
        updateTask()
    }

    @Test
    @ExpectedDatabase(
        value = "update-task-should-not-change-assignee.xml",
        assertionMode = DatabaseAssertionMode.NON_STRICT
    )
    fun `nao deve alterar o responsavel da tarefa no banco de dados`() {
        updateTask()
    }

    @Test
    @ExpectedDatabase(
        value = "update-task-should-not-change-tags.xml",
        assertionMode = DatabaseAssertionMode.NON_STRICT
    )
    fun `nao deve fazer nenhuma alteracao nas tags da tarefa no banco de dados`() {
        updateTask()
    }

    private fun updateTask(): ResultActions {
        val input = TaskFormDTO(
            id = Tasks.WriteLesson.ID,
            title = NEW_TITLE,
            description = NEW_DESCRIPTION
        )

        return mockMvc.perform(
            put("/api/task/{taskId}", Tasks.WriteLesson.ID)
                .contentType(MediaType.APPLICATION_JSON)
                .content(WebTestUtil.convertObjectToJsonBytes(input))
        )
    }
}

Os cinco arquivos XML de dataset esperado (update-task-should-*.xml) são exatamente os mesmos datasets enxutos que criamos na lição anterior de formulários web, provando como o design de datasets orientados a conceitos lógicos é 100% reutilizável entre aplicações MVC tradicionais e APIs REST!

Vamos resumir o que aprendemos nesta lição.


Resumo

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

  • Ao escrever testes de integração para um método de controller que insere dados no banco de dados, devemos resetar os valores gerados para a coluna id antes da execução de cada método de teste.
  • Ao escrever asserções que verificam o retorno de múltiplos erros de validação JSON, devemos lembrar que as violações são retornadas em ordem arbitrária e usar predicados JsonPath ($.fieldErrors[?(@.field == '...')]).
  • Ao escrever asserções para os dados persistidos no banco de dados, devemos utilizar datasets pequenos que validem um único conceito lógico por vez.