Testes de Integração de API REST: Leitura

Ao concluirmos esta aula, entenderemos como escrever testes de integração para uma API REST que lê dados do banco de dados relacional.

[!NOTE] Esta aula assume que:

  • Você sabe escrever testes de integração para aplicações web Spring (com JUnit Jupiter 6.0.3 ou JUnit 4/5).
  • Você sabe criar uma classe request builder que envia requisições HTTP para o sistema sob teste.
  • Você sabe escrever testes unitários para funções de controller que leem dados do banco de dados.
  • Você sabe configurar o sistema sob teste ao escrever testes de integração para uma API REST com Kotlin e JUnit Jupiter (conforme visto na aula anterior sobre configuração de testes de integração de API REST).

Introdução ao Sistema Sob Teste

Durante esta aula, escreveremos testes de integração para uma função de controller que processa requisições GET enviadas para o caminho /api/task/{id}. Essa função busca as informações da tarefa solicitada no banco de dados e retorna os dados encontrados no formato JSON.

Se nenhuma tarefa for encontrada no banco de dados, essa função retorna o código de status HTTP 404 (Not Found). Além disso, se um usuário anônimo tentar invocar essa função de controller, nossa API REST retornará o código de status HTTP 401 (Unauthorized).

O código-fonte da nossa classe de controller é estruturado da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.cleantestautomation.tb2020.task

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestMethod
import org.springframework.web.bind.annotation.RestController

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

    @RequestMapping(value = ["{id}"], method = [RequestMethod.GET])
    fun findById(@PathVariable("id") id: Long): TaskDTO {
        return service.findById(id)
    }
}

[!TIP] No ecossistema moderno com Spring Boot 4.1.2 e Kotlin 2.4.21: Você também pode declarar o mapeamento de rota de forma mais concisa utilizando @GetMapping("{id}"). O comportamento de roteamento e despacho do Spring MVC permanece idêntico em ambas as formas.

Se a tarefa solicitada for encontrada no banco de dados, nossa função de controller retorna 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
19
20
21
22
23
24
25
26
27
28
29
{
  "assignee": {
    "name": "John Doe",
    "userId": 1
  },
  "closer": {
    "name": "John Doe",
    "userId": 1
  },
  "creator": {
    "name": "John Doe",
    "userId": 1
  },
  "description": "This example contains integration tests",
  "id": 1,
  "modifier": {
    "name": "John Doe",
    "userId": 1
  },
  "resolution": "DONE",
  "status": "CLOSED",
  "tags": [
    {
      "id": 1,
      "name": "example"
    }
  ],
  "title": "Write example application"
}

A seguir, faremos um ajuste importante na configuração do sistema sob teste para permitir a autenticação correta dos nossos testes.

Alterando a Configuração do Sistema Sob Teste

Quando o sistema sob teste utiliza o Spring Security, podemos configurar o usuário autenticado da requisição por meio da anotação @WithUserDetails. No entanto, se utilizarmos essa anotação sem alterar a configuração que criamos nas aulas anteriores, notaremos que o framework Spring TestContext não consegue criar o contexto de segurança (SecurityContext) porque as informações do usuário autenticado ainda não existem no banco de dados:

1
2
3
4
5
6
java.lang.IllegalStateException: Unable to create SecurityContext using
@org.springframework.security.test.context.support.WithUserDetails(
setupBefore=TEST_METHOD, userDetailsServiceBeanName=, value=john.doe@gmail.com)

Caused by: org.springframework.security.core.userdetails.UsernameNotFoundException:
No user found with username: john.doe@gmail.com

Quando configuramos o usuário autenticado por meio da anotação @WithUserDetails, o WithSecurityContextTestExecutionListener cria um novo SecurityContext contendo as credenciais e autorizações do usuário. O problema reside na ordem de execução dos ouvintes (listeners): o SecurityContext é inicializado antes que nossos dados de teste sejam inseridos no banco de dados pela classe DbUnitTestExecutionListener. Como a tabela de usuários ainda se encontra vazia quando o Spring Security consulta o UserDetailsService, o usuário não é localizado.

Podemos solucionar esse problema seguindo dois passos:

Primeiro, precisamos criar um novo TestExecutionListener que estenda a classe DbUnitTestExecutionListener. Nosso novo ouvinte assegura que nossos dados de teste sejam populados no banco de dados antes que o SecurityContext seja montado pelo WithSecurityContextTestExecutionListener.

No framework Spring TestContext, ouvintes com valores de ordem numéricos menores possuem precedência mais alta (executam antes na fase beforeTestMethod). Como o ouvinte do Spring Security opera com uma prioridade padrão em torno de 10000, sobrescrevemos o método getOrder() retornando 9999:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package com.cleantestautomation.tb2020.common

import com.github.springtestdbunit.DbUnitTestExecutionListener

class OrderedDbUnitTestExecutionListener : DbUnitTestExecutionListener() {

    override fun getOrder(): Int {
        return 9999
    }
}

Segundo, precisamos garantir que nossos testes de integração passem a utilizar esse novo TestExecutionListener. Após essa alteração, o código-fonte da nossa meta-anotação @DatabaseIntegrationTest fica da seguinte forma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.cleantestautomation.tb2020.common

import com.github.springtestdbunit.DbUnitTestExecutionListener
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.junit.jupiter.SpringExtension

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@SpringBootTest
@TestExecutionListeners(
    listeners = [OrderedDbUnitTestExecutionListener::class],
    mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
@ActiveProfiles("integrationTest")
@Tag("integrationTest")
annotation class DatabaseIntegrationTest

[!NOTE] Essa anotação @DatabaseIntegrationTest opera em conjunto com a anotação @WebIntegrationTest criada na aula anterior (que combina @AutoConfigureMockMvc e @DatabaseIntegrationTest), garantindo que o PostgreSQL (gerenciado via contêiner pelo Testcontainers 2.0.5) receba os datasets do DbUnit antes que a camada de segurança resolva o usuário da requisição.

Vamos avançar e aprender a escrever os testes de integração para a nossa função de controller.

Escrevendo Testes de Integração para Nossa Função de Controller

Durante esta aula, verificaremos se o sistema sob teste funciona conforme o esperado quando o usuário autenticado for um usuário comum da aplicação. Faremos isso através de três passos bem definidos:

1. Criando as Classes Internas de Teste e Datasets

Primeiro, precisamos criar as classes internas aninhadas (@Nested) que conterão nossos testes de integração:

  1. Criar as classes internas que contêm as funções de teste responsáveis por verificar se o sistema sob teste funciona como esperado quando a tarefa solicitada não é encontrada e o usuário autenticado é um usuário comum.
  2. Criar as classes internas que contêm as funções de teste responsáveis por verificar se o sistema sob teste funciona como esperado quando a tarefa solicitada é encontrada e o usuário autenticado é um usuário comum.

Depois de criarmos a estrutura básica dessas classes internas, o código-fonte da nossa classe de teste fica 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
package com.cleantestautomation.tb2020.task

import com.cleantestautomation.tb2020.common.WebIntegrationTest
import com.cleantestautomation.tb2020.user.Users
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.github.springtestdbunit.dataset.ReplacementDataSetLoader
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.test.context.support.WithUserDetails
import org.springframework.test.web.servlet.MockMvc

@WebIntegrationTest
@DisplayName("Find task by id")
class FindTaskByIdApiTest(@Autowired private val mockMvc: MockMvc) {

    private val requestBuilder = TaskCrudRequestBuilder(mockMvc)

    @Nested
    @DisplayName("When the requested task isn't found")
    inner class WhenRequestedTaskIsNotFound {

        @Nested
        @WebIntegrationTest
        @DatabaseSetup(value = [
            "/com/testwithspring/master/users.xml",
            "/com/testwithspring/master/tasks.xml"
        ])
        @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
        @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
        @DisplayName("When the user is John Doe")
        inner class WhenUserIsJohnDoe {

        }
    }

    @Nested
    @DisplayName("When the requested task is found")
    inner class WhenRequestedTaskIsFound {

        @Nested
        @WebIntegrationTest
        @DatabaseSetup(value = [
            "/com/testwithspring/master/users.xml",
            "/com/testwithspring/master/tasks.xml"
        ])
        @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
        @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
        @DisplayName("When the user is John Doe")
        inner class WhenUserIsJohnDoe {

        }
    }
}

[!IMPORTANT] Anotações em Classes Internas com @Nested: No JUnit Jupiter e no Spring TestContext framework, quando uma classe interna (inner class) contém métodos de teste anotados com @Test, é fundamental declarar as anotações de configuração (@WebIntegrationTest, @DatabaseSetup, @DbUnitConfiguration e @WithUserDetails) diretamente na classe interna que encapsula a execução. Dessa forma, garantimos que o ciclo de vida do Spring e a carga dos datasets do banco de dados sejam aplicados no escopo correto de cada grupo de testes.

O dataset do DbUnit (users.xml), que insere os usuários necessários no banco de dados, possui a seguinte estrutura:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<dataset>
    <user_accounts id="1"
                   email_address="john.doe@gmail.com"
                   is_enabled="true"
                   name="John Doe"
                   password="user"
                   role="ROLE_USER"/>
    <user_accounts id="2"
                   email_address="anne.admin@gmail.com"
                   is_enabled="true"
                   name="Anne Admin"
                   password="admin"
                   role="ROLE_ADMIN"/>
</dataset>

O dataset do DbUnit (tasks.xml), que popula tarefas e tags no banco de dados relacional, possui a seguinte estrutura:

 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
<dataset>
    <tasks id="1"
           assignee_id="1"
           closer_id="1"
           creator_id="1"
           description="This example contains integration tests"
           modifier_id="1"
           resolution="DONE"
           status="CLOSED"
           title="Write example application"/>
    <tasks id="2"
           assignee_id="[null]"
           closer_id="[null]"
           creator_id="1"
           description="This lesson talks about integration testing"
           modifier_id="1"
           resolution="[null]"
           status="OPEN"
           title="Write lesson"/>

    <tags id="1"
          name="example"/>
    <tags id="2"
          name="lesson"/>

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

A classe Users é uma classe utilitária de constantes que espelha os dados de teste inseridos na tabela user_accounts. A parte relevante do seu código-fonte é:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.cleantestautomation.tb2020.user

class Users {

    class JohnDoe {

        companion object {
            const val ID = 1L
            const val EMAIL_ADDRESS = "john.doe@gmail.com"
            const val NAME = "John Doe"
        }
    }
}

[!NOTE] A aplicação de exemplo completa também possui testes de integração que asseguram o funcionamento da API quando o usuário é anônimo (retornando HTTP 401) ou quando o usuário autenticado é um administrador do sistema. Você pode consultar esses cenários adicionais diretamente no repositório de código.


2. Testando o Cenário em que a Tarefa Não é Encontrada

Segundo, precisamos escrever as funções de teste que garantem que o sistema sob teste funcione como esperado quando o identificador de tarefa informado não existir no banco de dados. Podemos fazer isso seguindo estes dois passos:

  1. Assegurar que nossa API REST retorne o código de status HTTP 404 (Not Found).
  2. Verificar que nossa API REST retorne um corpo de resposta HTTP vazio.

A classe Tasks é uma classe de constantes que centraliza os identificadores e dados esperados das tarefas nos testes. Definimos o ID inexistente da seguinte forma:

 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
package com.cleantestautomation.tb2020.task

import com.cleantestautomation.tb2020.user.Users

class Tasks {

    companion object {
        const val ID_NOT_FOUND = 99L
    }

    class WriteExampleApplication {

        companion object {
            const val ID = 1L
            const val ASSIGNEE_ID = Users.JohnDoe.ID
            const val ASSIGNEE_NAME = Users.JohnDoe.NAME
            const val CLOSER_ID = Users.JohnDoe.ID
            const val CLOSER_NAME = Users.JohnDoe.NAME
            const val CREATOR_ID = Users.JohnDoe.ID
            const val CREATOR_NAME = Users.JohnDoe.NAME
            const val DESCRIPTION = "This example contains integration tests"
            const val MODIFIER_ID = Users.JohnDoe.ID
            const val MODIFIER_NAME = Users.JohnDoe.NAME
            const val TITLE = "Write example application"
            val RESOLUTION = TaskResolution.DONE
            val STATUS = TaskStatus.CLOSED
        }

        class Tags {

            class Example {

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

Depois de adicionarmos as funções de teste para o cenário de tarefa inexistente, a seção correspondente da nossa classe de teste fica 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
package com.cleantestautomation.tb2020.task

import com.cleantestautomation.tb2020.common.WebIntegrationTest
import com.cleantestautomation.tb2020.user.Users
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.github.springtestdbunit.dataset.ReplacementDataSetLoader
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.test.context.support.WithUserDetails
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@WebIntegrationTest
@DisplayName("Find task by id")
class FindTaskByIdApiTest(@Autowired private val mockMvc: MockMvc) {

    private val requestBuilder = TaskCrudRequestBuilder(mockMvc)

    @Nested
    @DisplayName("When the requested task isn't found")
    inner class WhenRequestedTaskIsNotFound {

        @Nested
        @WebIntegrationTest
        @DatabaseSetup(value = [
            "/com/testwithspring/master/users.xml",
            "/com/testwithspring/master/tasks.xml"
        ])
        @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
        @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
        @DisplayName("When the user is John Doe")
        inner class WhenUserIsJohnDoe {

            @Test
            @DisplayName("Should return the HTTP status code not found")
            fun shouldReturnHttpStatusCodeNotFound() {
                requestBuilder.findById(Tasks.ID_NOT_FOUND)
                    .andExpect(status().isNotFound)
            }

            @Test
            @DisplayName("Should return an empty HTTP response")
            fun shouldReturnEmptyHttpResponse() {
                requestBuilder.findById(Tasks.ID_NOT_FOUND)
                    .andExpect(content().string(""))
            }
        }
    }
}

[!TIP] Equivalência com a MockMvc Kotlin DSL: Caso você opte por utilizar a DSL nativa do Spring MVC em Kotlin (mockMvc.get(...)), o teste de recurso não encontrado pode ser escrito com o mesmo comportamento expressivo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import org.springframework.test.web.servlet.get

@Test
@DisplayName("Deve retornar status 404 e corpo vazio quando tarefa nao for encontrada")
fun shouldReturnNotFoundAndEmptyBody() {
    mockMvc.get("/api/task/{id}", Tasks.ID_NOT_FOUND)
        .andExpect {
            status { isNotFound() }
            content { string("") }
        }
}

3. Testando o Cenário em que a Tarefa é Encontrada

Terceiro, precisamos escrever as funções de teste que asseguram que o sistema sob teste funcione como esperado quando a tarefa solicitada for encontrada com sucesso no banco de dados. Podemos fazer isso validando cada aspecto da resposta através destes 11 passos:

  1. Assegurar que nossa API REST retorne o código de status HTTP 200 (OK).
  2. Verificar que nossa API REST retorne as informações encontradas com o cabeçalho Content-Type igual a application/json.
  3. Assegurar que o ID retornado no JSON coincida com o identificador da tarefa pesquisada.
  4. Verificar que a API retorne corretamente o nome e o userId do responsável pela tarefa (assignee).
  5. Assegurar que a API retorne os dados da pessoa que concluiu/fechou a tarefa (closer).
  6. Verificar que a API retorne as informações de quem criou a tarefa (creator).
  7. Assegurar que a API retorne as informações de quem modificou a tarefa pela última vez (modifier).
  8. Verificar que a resposta contenha o título e a descrição exatos da tarefa recuperada.
  9. Assegurar que o status retornado seja CLOSED e a resolução seja DONE.
  10. Verificar que o array de tags da tarefa contenha exatamente 1 elemento.
  11. Assegurar que as informações da tag associada (id e name) estejam corretas no JSON.

Após implementarmos essas funções de teste, a classe interna correspondente fica da seguinte forma:

  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
171
172
173
174
package com.cleantestautomation.tb2020.task

import com.cleantestautomation.tb2020.common.WebIntegrationTest
import com.cleantestautomation.tb2020.user.Users
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.github.springtestdbunit.dataset.ReplacementDataSetLoader
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.hasSize
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.security.test.context.support.WithUserDetails
import org.springframework.test.web.servlet.MockMvc
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

@WebIntegrationTest
@DisplayName("Find task by id")
class FindTaskByIdApiTest(@Autowired private val mockMvc: MockMvc) {

    private val requestBuilder = TaskCrudRequestBuilder(mockMvc)

    @Nested
    @DisplayName("When the requested task is found")
    inner class WhenRequestedTaskIsFound {

        @Nested
        @WebIntegrationTest
        @DatabaseSetup(value = [
            "/com/testwithspring/master/users.xml",
            "/com/testwithspring/master/tasks.xml"
        ])
        @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
        @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
        @DisplayName("When the user is John Doe")
        inner class WhenUserIsJohnDoe {

            @Test
            @DisplayName("Should return the HTTP status code ok")
            fun shouldReturnHttpStatusCodeOk() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(status().isOk)
            }

            @Test
            @DisplayName("Should return the information of the found task as Json")
            fun shouldReturnInformationOfFoundTaskAsJson() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(
                        content().contentType(MediaType.APPLICATION_JSON)
                    )
            }

            @Test
            @DisplayName("Should return the information of the found task")
            fun shouldReturnInformationOfFoundTask() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(jsonPath(
                        "$.id",
                        equalTo(Tasks.WriteExampleApplication.ID.toInt())
                    ))
            }

            @Test
            @DisplayName("Should return the information of the assignee")
            fun shouldReturnInformationOfAssignee() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(jsonPath(
                        "$.assignee.name",
                        equalTo(Tasks.WriteExampleApplication.ASSIGNEE_NAME)
                    ))
                    .andExpect(jsonPath(
                        "$.assignee.userId",
                        equalTo(Tasks.WriteExampleApplication.ASSIGNEE_ID.toInt())
                    ))
            }

            @Test
            @DisplayName("Should return the information of the closer")
            fun shouldReturnInformationOfCloser() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(jsonPath(
                        "$.closer.name",
                        equalTo(Tasks.WriteExampleApplication.CLOSER_NAME)
                    ))
                    .andExpect(jsonPath(
                        "$.closer.userId",
                        equalTo(Tasks.WriteExampleApplication.CLOSER_ID.toInt())
                    ))
            }

            @Test
            @DisplayName("Should return the information of the creator")
            fun shouldReturnInformationOfCreator() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(jsonPath(
                        "$.creator.name",
                        equalTo(Tasks.WriteExampleApplication.CREATOR_NAME)
                    ))
                    .andExpect(jsonPath(
                        "$.creator.userId",
                        equalTo(Tasks.WriteExampleApplication.CREATOR_ID.toInt())
                    ))
            }

            @Test
            @DisplayName("Should return the information of the modifier")
            fun shouldReturnInformationOfModifier() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(jsonPath(
                        "$.modifier.name",
                        equalTo(Tasks.WriteExampleApplication.MODIFIER_NAME)
                    ))
                    .andExpect(jsonPath(
                        "$.modifier.userId",
                        equalTo(Tasks.WriteExampleApplication.MODIFIER_ID.toInt())
                    ))
            }

            @Test
            @DisplayName("Should return the correct description and title")
            fun shouldReturnCorrectDescriptionAndTitle() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(jsonPath(
                        "$.description",
                        equalTo(Tasks.WriteExampleApplication.DESCRIPTION)
                    ))
                    .andExpect(jsonPath(
                        "$.title",
                        equalTo(Tasks.WriteExampleApplication.TITLE)
                    ))
            }

            @Test
            @DisplayName("Should return a task that was closed because it was done")
            fun shouldReturnTaskThatIsDone() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(jsonPath(
                        "$.resolution",
                        equalTo(Tasks.WriteExampleApplication.RESOLUTION.name)
                    ))
                    .andExpect(jsonPath(
                        "$.status",
                        equalTo(Tasks.WriteExampleApplication.STATUS.name)
                    ))
            }

            @Test
            @DisplayName("Should return a task that has one tag")
            fun shouldReturnTaskThatHasOneTag() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(jsonPath("$.tags", hasSize<Any>(1)))
            }

            @Test
            @DisplayName("Should return the information of the found tag")
            fun shouldReturnInformationOfFoundTag() {
                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
                    .andExpect(jsonPath(
                        "$.tags[0].id",
                        equalTo(Tasks.WriteExampleApplication.Tags.Example.ID.toInt())
                    ))
                    .andExpect(jsonPath(
                        "$.tags[0].name",
                        equalTo(Tasks.WriteExampleApplication.Tags.Example.NAME)
                    ))
            }
        }
    }
}

[!TIP] Validação com MockMvc Kotlin DSL e AssertJ: Com a DSL nativa do Kotlin no Spring Framework 7 / Spring Boot 4.1.2, todas as assertivas acima podem ser combinadas em um bloco conciso e tipado:

 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
import org.springframework.test.web.servlet.get

@Test
@DisplayName("Deve retornar todos os dados detalhados da tarefa via MockMvc Kotlin DSL")
fun shouldReturnDetailedTaskDataWithKotlinDsl() {
    mockMvc.get("/api/task/{id}", Tasks.WriteExampleApplication.ID) {
        accept = MediaType.APPLICATION_JSON
    }.andExpect {
        status { isOk() }
        content { contentType(MediaType.APPLICATION_JSON) }
        jsonPath("$.id") { value(Tasks.WriteExampleApplication.ID.toInt()) }
        jsonPath("$.assignee.name") { value(Tasks.WriteExampleApplication.ASSIGNEE_NAME) }
        jsonPath("$.assignee.userId") { value(Tasks.WriteExampleApplication.ASSIGNEE_ID.toInt()) }
        jsonPath("$.closer.name") { value(Tasks.WriteExampleApplication.CLOSER_NAME) }
        jsonPath("$.closer.userId") { value(Tasks.WriteExampleApplication.CLOSER_ID.toInt()) }
        jsonPath("$.creator.name") { value(Tasks.WriteExampleApplication.CREATOR_NAME) }
        jsonPath("$.creator.userId") { value(Tasks.WriteExampleApplication.CREATOR_ID.toInt()) }
        jsonPath("$.modifier.name") { value(Tasks.WriteExampleApplication.MODIFIER_NAME) }
        jsonPath("$.modifier.userId") { value(Tasks.WriteExampleApplication.MODIFIER_ID.toInt()) }
        jsonPath("$.description") { value(Tasks.WriteExampleApplication.DESCRIPTION) }
        jsonPath("$.title") { value(Tasks.WriteExampleApplication.TITLE) }
        jsonPath("$.resolution") { value(Tasks.WriteExampleApplication.RESOLUTION.name) }
        jsonPath("$.status") { value(Tasks.WriteExampleApplication.STATUS.name) }
        jsonPath("$.tags.length()") { value(1) }
        jsonPath("$.tags[0].id") { value(Tasks.WriteExampleApplication.Tags.Example.ID.toInt()) }
        jsonPath("$.tags[0].name") { value(Tasks.WriteExampleApplication.Tags.Example.NAME) }
    }
}

Ambas as abordagens se beneficiam da infraestrutura compartilhada com Testcontainers 2.0.5 PostgreSQL e isolamento via datasets do DbUnit.

Agora estamos prontos para escrever testes de integração completos e robustos para APIs REST que leem dados do banco de dados.

Resumo

Esta aula nos ensinou duas lições fundamentais para arquiteturas de teste corporativas:

  • Se o sistema sob teste utiliza Spring Security e queremos usar o Spring Test DbUnit (com banco de dados PostgreSQL real no Testcontainers 2.0.5), precisamos estender a classe DbUnitTestExecutionListener e garantir que nossos dados de teste sejam inseridos no banco de dados antes que o SecurityContext seja montado pelo WithSecurityContextTestExecutionListener. Fazemos isso ajustando o método getOrder() para retornar um valor menor que a ordem do ouvinte de segurança (como 9999).
  • Se quisermos escrever testes de integração aninhados (@Nested), devemos declarar todas as anotações de configuração (@WebIntegrationTest, @DatabaseSetup, @DbUnitConfiguration e @WithUserDetails) em cada classe interna que contenha métodos de teste, garantindo o correto provisionamento do contexto Spring e dos datasets de teste.