Orquestrando Testes de Integração com Extensões do Spring

Depois de concluirmos esta aula, nós:

  • Compreenderemos como o módulo spring-test integra o Spring TestContext Framework ao modelo de extensões do JUnit Jupiter por meio da SpringExtension.
  • Saberemos como configurar testes de integração tanto com anotações explícitas (@ExtendWith(SpringExtension::class)) quanto com anotações compostas (@SpringJUnitConfig, @SpringJUnitWebConfig e @SpringBootTest).
  • Saberemos como escrever testes de integração para controladores Spring MVC que consultam o banco de dados tanto usando uma estrutura plana tradicional quanto usando uma estrutura hierárquica com @Nested.
  • Saberemos como criar nossas próprias meta-anotações compostas para eliminar a repetição de anotações em classes @Nested.

Vamos começar revisando as dependências necessárias.


1. Obtendo as Dependências Necessárias

Historicamente, quando o JUnit 5 foi lançado antes do Spring Framework 5, era necessário adicionar uma biblioteca externa experimental (spring-test-junit5 de Sam Brannen) para integrar o Spring TestContext Framework ao JUnit Jupiter.

A partir do Spring Framework 5 — e nativamente no Spring Framework 7 / Spring Boot 4.1.2 com JUnit 6.0.3 — a extensão SpringExtension faz parte diretamente do artefato oficial spring-test!

  • Name: Spring TestContext Framework
  • Group ID: org.springframework
  • Artifact ID: spring-test

Quando utilizamos spring-boot-starter-test (ou spring-boot-starter-webmvc-test) no nosso build.gradle.kts, tanto spring-test quanto junit-jupiter já estão prontos para uso:

1
2
3
4
dependencies {
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
}

A seguir, vamos conhecer o controlador que será o nosso sistema sob teste (SUT).


2. Conhecendo o Sistema Sob Teste (SUT)

Durante esta aula, escreveremos testes de integração para uma classe chamada MessageController. Ela possui um único endpoint que processa requisições HTTP GET enviadas para o caminho /api/message, consulta todas as mensagens salvas no banco de dados relacional e retorna a lista serializada como um array de objetos JSON:

1
2
3
4
5
6
[
  {
    "id": 1,
    "text": "Hello World!"
  }
]

Caso nenhuma mensagem seja encontrada no banco de dados, nosso endpoint retorna uma lista JSON vazia ([]) com status 200 OK:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.example.message

import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/message")
class MessageController(
    private val messageRepository: MessageRepository
) {

    @GetMapping
    fun findAll(): List<MessageDTO> =
        messageRepository.findAll().map { MessageDTO(id = it.id, text = it.text) }
}

Vamos ver agora como configurar nossa classe de teste de integração com a extensão do Spring para o JUnit Jupiter.


3. Configurando a Classe de Teste com SpringExtension

Para habilitar o suporte ao Spring TestContext Framework em um teste JUnit 6, precisamos registrar a extensão SpringExtension. Existem três maneiras de fazer isso, da mais explícita (baixo nível) até a mais concisa (alto nível):

Abordagem 1: Configuração Explícita com @ExtendWith(SpringExtension::class)

Em uma aplicação Spring MVC tradicional sem auto-configuração do Spring Boot, combinaríamos @ExtendWith(SpringExtension::class) com @ContextConfiguration e @WebAppConfiguration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@ExtendWith(SpringExtension::class)
@ContextConfiguration(classes = [ExampleContext::class])
@WebAppConfiguration
@TestExecutionListeners(
    value = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        SqlScriptsTestExecutionListener::class
    ],
    mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
class FindAllMessagesExplicitIT {
    // ...
}

Abordagem 2: Anotação Composta @SpringJUnitWebConfig

O próprio spring-test oferece duas anotações compostas (composed annotations) que já incluem @ExtendWith(SpringExtension::class) internamente:

  1. @SpringJUnitConfig: combina @ExtendWith(SpringExtension::class) + @ContextConfiguration.
  2. @SpringJUnitWebConfig: combina @ExtendWith(SpringExtension::class) + @ContextConfiguration + @WebAppConfiguration.

Com @SpringJUnitWebConfig, podemos simplificar a declaração acima para:

1
2
3
4
@SpringJUnitWebConfig(classes = [ExampleContext::class])
class FindAllMessagesWebConfigIT {
    // ...
}

Abordagem 3: @SpringBootTest + @AutoConfigureMockMvc (Padrão Moderno no Spring Boot 4)

Em aplicações Spring Boot 4.1.2, todas as anotações de teste do Spring Boot (@SpringBootTest, @WebMvcTest, @DataJpaTest) já são meta-anotadas com @ExtendWith(SpringExtension::class)! Isso significa que você não precisa declarar @ExtendWith(SpringExtension::class) manualmente quando usa @SpringBootTest.

Vamos escrever nossa classe FindAllMessagesIT configurando o MockMvc e populando o banco de dados com um script SQL (messages.sql) que insere a mensagem de ID 1 e texto "Hello World!":

1
2
3
4
-- src/integrationTest/resources/com/example/message/messages.sql
TRUNCATE TABLE messages RESTART IDENTITY CASCADE;

INSERT INTO messages (id, text) VALUES (1, 'Hello World!');

E um script cleardb.sql para limpar a tabela nos cenários vazios:

1
2
-- src/integrationTest/resources/com/example/message/cleardb.sql
TRUNCATE TABLE messages RESTART IDENTITY CASCADE;

4. Escrevendo Testes de Integração Planos (Flat Structure)

Primeiro, vamos escrever nossa classe FindAllMessagesIT utilizando a estrutura tradicional (plana) de métodos @Test. Testaremos três verificações para cada um dos dois cenários (quando nenhuma mensagem é encontrada e quando uma mensagem é encontrada):

  1. O status HTTP retornado deve ser 200 OK.
  2. O Content-Type da resposta deve ser application/json.
  3. O corpo JSON retornado deve conter a lista esperada de mensagens.
 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
package com.example.message

import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.DisplayName
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.http.MediaType
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.web.servlet.MockMvc
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

@SpringBootTest
@AutoConfigureMockMvc
@Sql("/com/example/message/cleardb.sql")
class FindAllMessagesIT(
    @Autowired private val mockMvc: MockMvc
) {

    @Test
    @DisplayName("Deve retornar o status HTTP OK quando nenhuma mensagem for encontrada")
    fun shouldReturnHttpStatusCodeOkWhenNoMessagesIsFound() {
        mockMvc.perform(get("/api/message"))
            .andExpect(status().isOk)
    }

    @Test
    @DisplayName("Deve retornar as mensagens encontradas como JSON quando nenhuma mensagem for encontrada")
    fun shouldReturnFoundMessagesAsJsonWhenNoMessagesIsFound() {
        mockMvc.perform(get("/api/message"))
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
    }

    @Test
    @DisplayName("Deve retornar uma lista vazia quando nenhuma mensagem for encontrada")
    fun shouldReturnEmptyListWhenNoMessagesIsFound() {
        mockMvc.perform(get("/api/message"))
            .andExpect(jsonPath("$", hasSize<Any>(0)))
    }

    @Test
    @Sql("/com/example/message/messages.sql")
    @DisplayName("Deve retornar o status HTTP OK quando uma mensagem for encontrada")
    fun shouldReturnHttpStatusCodeOkWhenOneMessageIsFound() {
        mockMvc.perform(get("/api/message"))
            .andExpect(status().isOk)
    }

    @Test
    @Sql("/com/example/message/messages.sql")
    @DisplayName("Deve retornar as mensagens encontradas como JSON quando uma mensagem for encontrada")
    fun shouldReturnFoundMessagesAsJsonWhenOneMessageIsFound() {
        mockMvc.perform(get("/api/message"))
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
    }

    @Test
    @Sql("/com/example/message/messages.sql")
    @DisplayName("Deve retornar uma única mensagem quando uma mensagem for encontrada")
    fun shouldReturnOneMessageWhenOneMessageIsFound() {
        mockMvc.perform(get("/api/message"))
            .andExpect(jsonPath("$", hasSize<Any>(1)))
            .andExpect(jsonPath("$[0].id", `is`(1)))
            .andExpect(jsonPath("$[0].text", `is`("Hello World!")))
    }
}

[!NOTE] Observe também como o JUnit Jupiter + SpringExtension permite injeção de dependência diretamente no construtor da classe de teste (class FindAllMessagesIT(@Autowired private val mockMvc: MockMvc)). Isso torna a propriedade mockMvc imutável (val) e elimina a necessidade de lateinit var!

Embora os testes acima sejam fáceis de entender, note que tivemos que repetir @Sql("/com/example/message/messages.sql") e o sufixo WhenOneMessageIsFound em três métodos diferentes. Vamos ver como melhorar essa estrutura usando @Nested.


5. Estruturando Testes de Integração Hierárquicos com @Nested

Com a anotação @Nested do JUnit 6, podemos agrupar os casos de teste de cada estado do banco de dados em uma inner class dedicada (WhenNoMessagesIsFound e WhenOneMessageIsFound) e colocar a anotação @Sql diretamente na classe @Nested!

[!TIP] No Spring Framework moderno (5.0+ até o Spring 7), a SpringExtension propaga automaticamente a configuração do contexto (@SpringBootTest, @ContextConfiguration, @Sql) da classe externa para todas as classes internas @Nested. Caso você utilize @SpringJUnitWebConfig em contextos legados específicos que exijam redeclaração, podemos criar uma meta-anotação composta para evitar qualquer duplicação.

Vamos refatorar nossa classe FindAllMessagesIT utilizando @Nested:

 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
package com.example.message

import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.web.servlet.MockMvc
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

@SpringBootTest
@AutoConfigureMockMvc
@DisplayName("Buscar todas as mensagens via GET /api/message")
class FindAllMessagesNestedIT(
    @Autowired private val mockMvc: MockMvc
) {

    @Nested
    @Sql("/com/example/message/cleardb.sql")
    @DisplayName("Quando nenhuma mensagem é encontrada no banco de dados")
    inner class WhenNoMessagesIsFound {

        @Test
        @DisplayName("Deve retornar o código de status HTTP OK (200)")
        fun shouldReturnHttpStatusCodeOk() {
            mockMvc.perform(get("/api/message"))
                .andExpect(status().isOk)
        }

        @Test
        @DisplayName("Deve retornar as mensagens encontradas como JSON")
        fun shouldReturnFoundMessagesAsJson() {
            mockMvc.perform(get("/api/message"))
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
        }

        @Test
        @DisplayName("Deve retornar uma lista vazia")
        fun shouldReturnEmptyList() {
            mockMvc.perform(get("/api/message"))
                .andExpect(jsonPath("$", hasSize<Any>(0)))
        }
    }

    @Nested
    @Sql("/com/example/message/messages.sql")
    @DisplayName("Quando uma mensagem é encontrada no banco de dados")
    inner class WhenOneMessageIsFound {

        @Test
        @DisplayName("Deve retornar o código de status HTTP OK (200)")
        fun shouldReturnHttpStatusCodeOk() {
            mockMvc.perform(get("/api/message"))
                .andExpect(status().isOk)
        }

        @Test
        @DisplayName("Deve retornar as mensagens encontradas como JSON")
        fun shouldReturnFoundMessagesAsJson() {
            mockMvc.perform(get("/api/message"))
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
        }

        @Test
        @DisplayName("Deve retornar uma única mensagem com ID 1 e texto 'Hello World!'")
        fun shouldReturnOneMessage() {
            mockMvc.perform(get("/api/message"))
                .andExpect(jsonPath("$", hasSize<Any>(1)))
                .andExpect(jsonPath("$[0].id", `is`(1)))
                .andExpect(jsonPath("$[0].text", `is`("Hello World!")))
        }
    }
}

6. Criando Meta-Anotações Compostas para Testes de Integração

Uma das funcionalidades mais poderosas da combinação entre JUnit 6 e Spring é o suporte de primeira classe a meta-anotações (composed annotations).

Se dezenas de classes de teste de integração da nossa aplicação repetem o mesmo conjunto de anotações de infraestrutura (@SpringBootTest, @AutoConfigureMockMvc, @ActiveProfiles("integrationTest"), etc.), podemos agrupá-las em uma única anotação customizada do nosso projeto, como @IntegrationTest ou @NestedSpringWebConfig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package com.example.config

import org.junit.jupiter.api.Tag
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integrationTest")
@Tag("integrationTest")
annotation class WebIntegrationTest

Agora, qualquer classe de teste de integração web da nossa suíte precisa apenas de @WebIntegrationTest:

1
2
3
4
5
6
7
@WebIntegrationTest
@DisplayName("Buscar todas as mensagens via GET /api/message")
class FindAllMessagesNestedIT(
    @Autowired private val mockMvc: MockMvc
) {
    // ...
}

Resumo

Nesta aula bônus, aprendemos como o Spring se integra ao JUnit 6:

  • A extensão SpringExtension (incluída no spring-test) conecta o ciclo de vida do Spring TestContext Framework ao JUnit Jupiter.
  • Graças ao ParameterResolver implementado pela SpringExtension, podemos injetar beans do Spring (como MockMvc, JdbcTemplate ou repositórios) diretamente no construtor da nossa classe de teste.
  • Utilizando classes @Nested combinadas com @Sql em nível de classe interna, eliminamos a repetição de scripts de preparação de banco e criamos relatórios de teste de integração legíveis e organizados por estado do banco de dados.
  • Com meta-anotações customizadas (como @WebIntegrationTest), padronizamos toda a configuração de infraestrutura da nossa suíte em uma única anotação semântica.