01. Introdução aos Testes de Integração

Ao concluirmos todas as aulas deste tópico, nós:

  • Compreenderemos por que não devemos misturar testes unitários e testes de integração no mesmo diretório ou tarefa de execução.
  • Saberemos como isolar e executar nossos testes de integração em fases dedicadas do ciclo de vida do build usando o plugin nativo jvm-test-suite do Gradle 9.8.2 (e perfis no Maven).
  • Saberemos como configurar do zero um ambiente reproduzível e profissional de testes de integração com Spring Boot 4, Testcontainers 2.0.5 e PostgreSQL.

Como Configurar o Ambiente para Testes de Integração com Testcontainers e PostgreSQL

Testes de integração modernos em aplicações Spring Boot exigem fidelidade aos componentes de infraestrutura de produção. No passado, era comum utilizar bancos em memória como H2 ou HSQLDB nos testes de integração. No entanto, essa abordagem frequentemente oculta diferenças críticas de dialeto SQL, tipos de dados proprietários (como JSONB, UUID nativo e arrays no PostgreSQL), funções de data/hora, restrições de integridade e características de concorrência e locking.

Com Testcontainers e o suporte nativo do Spring Boot 4 (@ServiceConnection), nós executamos nossos testes de integração contra instâncias reais e efêmeras do PostgreSQL provisionadas sob demanda em contêineres Docker.

Abaixo, apresentamos o guia prático completo e repetível para criar e estruturar esse ambiente no seu projeto com Gradle 9.8.2 (Kotlin DSL).


1. Requisitos de Sistema e Pré-requisitos de Runtime

Antes de executar testes de integração com Testcontainers, precisamos garantir que o runtime de contêineres e as ferramentas de desenvolvimento estejam instalados e em execução.

Requisitos Obrigatórios

  1. JDK 21 ou superior (Gerenciado via SDKMAN):

    • O Spring Boot 4 exige no mínimo o Java 21 (ou Java 25 LTS).
    • Instale e ative facilmente com o SDKMAN:
      1
      2
      3
      
      sdk install java 21.0.6-tem
      sdk use java 21.0.6-tem
      java -version
  2. Docker Engine ou Docker Desktop:

    • É necessário ter um daemon do Docker acessível via socket local (/var/run/docker.sock).
    • Verifique se o daemon está respondendo com o comando:
      1
      
      docker info
    • Se o comando retornar as informações do daemon sem erros de permissão, seu ambiente Docker está pronto.

[!NOTE] No Linux, certifique-se de que o seu usuário pertence ao grupo docker para evitar a necessidade de sudo:

1
2
sudo usermod -aG docker $USER
# E reinicie sua sessão do terminal ou execute: newgrp docker

Utilizando Podman como Alternativa ao Docker

Se você utiliza o Podman em substituição ao Docker, ative o serviço do socket de compatibilidade Docker da sua sessão de usuário:

1
2
3
4
5
6
# 1. Inicie o socket do Podman no modo rootless
systemctl --user enable --now podman.socket

# 2. Defina as variáveis de ambiente necessárias para o Testcontainers
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"
export TESTCONTAINERS_RYUK_DISABLED="true"

Valide a conectividade com:

1
podman info

2. Gerando o Projeto via curl com start.spring.io

Podemos iniciar um novo projeto Spring Boot completo diretamente pela linha de comando usando a API REST do Spring Initializr com o utilitário curl.

Execute o comando a seguir no terminal para gerar e descompactar o projeto base:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
curl -s https://start.spring.io/starter.zip \
  -d type=gradle-project-kotlin \
  -d language=kotlin \
  -d bootVersion=4.1.2 \
  -d baseDir=integration-tests-demo \
  -d groupId=com.testwithspring \
  -d artifactId=integration-tests-demo \
  -d name=integration-tests-demo \
  -d description="Testes de Integração com Spring Boot, Testcontainers e PostgreSQL" \
  -d packageName=com.testwithspring.demo \
  -d packaging=jar \
  -d javaVersion=21 \
  -d dependencies=web,data-jpa,postgresql,testcontainers,flyway \
  -o integration-tests-demo.zip

unzip -q integration-tests-demo.zip
cd integration-tests-demo

Detalhamento das Flags do Spring Initializr

  • type=gradle-project-kotlin: Seleciona o projeto Gradle configurado com Kotlin DSL (build.gradle.kts).
  • language=kotlin: Define o Kotlin como linguagem de programação do código-fonte.
  • bootVersion=4.1.2: Versão moderna do Spring Boot.
  • javaVersion=21: Define o target de compilação da JVM.
  • dependencies:
    • web: Adiciona o Spring MVC (spring-boot-starter-web) e Tomcat embutido.
    • data-jpa: Adiciona o Spring Data JPA e Hibernate para persistência relacional.
    • postgresql: Driver JDBC oficial do PostgreSQL.
    • testcontainers: Adiciona a biblioteca base do Testcontainers e os starters de integração de teste do Spring Boot.
    • flyway: Gerenciamento e versionamento automatizado de schemas SQL.

3. Configuração no build.gradle.kts com jvm-test-suite no Gradle 9.8.2

No Gradle 9.8.2, o plugin nativo jvm-test-suite é o padrão recomendado e canônico para declarar e gerenciar múltiplas suítes de teste isoladas. Ele elimina completamente a necessidade de plugins de terceiros ou configurações manuais complexas de sourceSets e tasks Test.

Objetivos do Nosso Build

  1. Manter os testes unitários rápidos e isolados no diretório padrão src/test/kotlin.
  2. Criar uma suíte dedicada integrationTest com código em src/integration-test/kotlin e recursos em src/integration-test/resources.
  3. Adicionar dependências exclusivas de teste de integração (Testcontainers PostgreSQL, @ServiceConnection, Flyway e AssertJ) apenas à suíte de integração.
  4. Garantir que os testes unitários sejam executados antes dos testes de integração (shouldRunAfter(test)).
  5. Vincular a suíte de integração à verificação geral do build (check.dependsOn(integrationTest)).
  6. Gerar relatórios HTML e XML separados para cada suíte.

O Arquivo build.gradle.kts Completo

Substitua o conteúdo do seu arquivo build.gradle.kts pela seguinte configuração declarativa e idiomática:

  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
plugins {
    kotlin("jvm") version "2.4.21"
    kotlin("plugin.spring") version "2.4.21"
    kotlin("plugin.jpa") version "2.4.21"
    id("org.springframework.boot") version "4.1.2"
    id("io.spring.dependency-management") version "1.1.7"
    `jvm-test-suite`
}

group = "com.testwithspring"
version = "0.0.1-SNAPSHOT"

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(21))
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // Dependências de produção
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.flywaydb:flyway-core")
    implementation("org.flywaydb:flyway-database-postgresql")
    runtimeOnly("org.postgresql:postgresql")
}

testing {
    suites {
        // -------------------------------------------------------------
        // 1. Suíte Padrão de Testes Unitários (src/test/kotlin)
        // -------------------------------------------------------------
        val test by getting(JvmTestSuite::class) {
            useJUnitJupiter("6.0.3")

            dependencies {
                implementation("org.springframework.boot:spring-boot-starter-test")
                implementation("io.mockk:mockk:1.14.9")
                implementation("org.assertj:assertj-core:3.27.7")
            }

            targets {
                all {
                    testTask.configure {
                        useJUnitPlatform {
                            includeTags("unitTest")
                        }
                        testLogging {
                            events("passed", "skipped", "failed")
                            showStandardStreams = true
                        }
                    }
                }
            }
        }

        // -------------------------------------------------------------
        // 2. Suíte Isolada de Testes de Integração (src/integration-test/kotlin)
        // -------------------------------------------------------------
        val integrationTest by registering(JvmTestSuite::class) {
            useJUnitJupiter("6.0.3")

            sources {
                kotlin.setSrcDirs(listOf("src/integration-test/kotlin"))
                resources.setSrcDirs(listOf("src/integration-test/resources"))
            }

            dependencies {
                // Permite enxergar as classes de produção do projeto principal
                implementation(project())

                // Spring Boot Test & Integração oficial com Testcontainers
                implementation("org.springframework.boot:spring-boot-starter-test")
                implementation("org.springframework.boot:spring-boot-testcontainers")

                // Módulos do Testcontainers 2.0.5 para PostgreSQL e JUnit 6
                implementation("org.testcontainers:testcontainers:2.0.5")
                implementation("org.testcontainers:junit-jupiter:2.0.5")
                implementation("org.testcontainers:postgresql:2.0.5")

                // Migração e asserções nos testes de integração
                implementation("org.flywaydb:flyway-core")
                implementation("org.flywaydb:flyway-database-postgresql")
                implementation("org.assertj:assertj-core:3.27.7")

                // Drivers e runtime da plataforma de testes
                runtimeOnly("org.postgresql:postgresql")
                runtimeOnly("org.junit.platform:junit-platform-launcher")
            }

            targets {
                all {
                    testTask.configure {
                        // Garante a pirâmide de testes: unitários rápidos rodam primeiro
                        shouldRunAfter(test)

                        useJUnitPlatform {
                            includeTags("integrationTest")
                        }

                        // Relatórios isolados para evitar que um sobrescreva o outro
                        reports {
                            html.outputLocation.set(layout.buildDirectory.dir("reports/tests/integrationTest"))
                            junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/integrationTest"))
                        }

                        testLogging {
                            events("passed", "skipped", "failed")
                            showStandardStreams = true
                        }
                    }
                }
            }
        }
    }
}

// Vincula a task check do ciclo de vida padrão do Gradle à suíte de integração
tasks.named("check") {
    dependsOn(testing.suites.named("integrationTest"))
}

O Papel do Spring Boot Testcontainers e @ServiceConnection

Observe a dependência:

1
implementation("org.springframework.boot:spring-boot-testcontainers")

Essa biblioteca introduz a anotação @ServiceConnection. Tradicionalmente, ao usar Testcontainers no Spring Boot, éramos obrigados a usar métodos estáticos complexos com @DynamicPropertySource para mapear manualmente a porta efêmera atribuída pelo Docker às propriedades spring.datasource.url, spring.datasource.username e spring.datasource.password:

1
2
3
4
5
6
7
// Abordagem antiga e verbosa (não mais necessária com @ServiceConnection)
@DynamicPropertySource
fun dynamicProperties(registry: DynamicPropertyRegistry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl)
    registry.add("spring.datasource.username", postgres::getUsername)
    registry.add("spring.datasource.password", postgres::getPassword)
}

Com o @ServiceConnection, basta anotar o campo do container PostgreSQLContainer! O Spring Boot detecta automaticamente a conexão do container em tempo de inicialização do teste e injeta todas as configurações de DataSource sem nenhum código boilerplate.


4. Estruturando os Diretórios e Criando o Primeiro Teste

Vamos agora criar a estrutura física de diretórios e escrever um teste de integração real para verificar o provisionamento do PostgreSQL.

Passo 1: Criando os Diretórios do Test Suite

No terminal da raiz do projeto, crie os diretórios de código e recursos da suíte integrationTest:

1
2
mkdir -p src/integration-test/kotlin/com/testwithspring/demo
mkdir -p src/integration-test/resources/db/migration

Passo 2: Criando o Script Flyway de Migração

Crie o arquivo src/integration-test/resources/db/migration/V1__create_customers_table.sql:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
CREATE TABLE customers (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(150) NOT NULL,
    email VARCHAR(200) NOT NULL UNIQUE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);

INSERT INTO customers (name, email) VALUES
    ('Ada Lovelace', 'ada.lovelace@example.com'),
    ('Alan Turing', 'alan.turing@example.com');

Passo 3: Criando o Arquivo de Propriedades de Teste

Crie o arquivo src/integration-test/resources/application-integration-test.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
spring:
  application:
    name: integration-tests-demo
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  flyway:
    enabled: true
    locations: classpath:db/migration

Passo 4: Escrevendo a Classe de Teste de Integração

Crie o arquivo src/integration-test/kotlin/com/testwithspring/demo/DatabaseIntegrationTest.kt:

 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
package com.testwithspring.demo

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.test.context.ActiveProfiles
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import javax.sql.DataSource

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("integration-test")
@Testcontainers
@Tag("integrationTest")
@DisplayName("Ambiente de Integração: Conectividade com PostgreSQL e Flyway")
class DatabaseIntegrationTest {

    companion object {
        /**
         * @Container e @ServiceConnection provisionam o container PostgreSQL 17
         * e registram automaticamente a URL, usuário e senha no Spring DataSource.
         */
        @Container
        @ServiceConnection
        @JvmStatic
        val postgres = PostgreSQLContainer("postgres:17-alpine")
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("secret")
    }

    @Autowired
    private lateinit var dataSource: DataSource

    @Test
    @DisplayName("Deve provisionar container PostgreSQL real e validar conexão ativa")
    fun `deve conectar com sucesso ao PostgreSQL via Testcontainers`() {
        assertThat(postgres.isRunning)
            .`as`("O container do PostgreSQL deve estar em execução no Docker")
            .isTrue()

        dataSource.connection.use { connection ->
            assertThat(connection.isValid(2))
                .`as`("A conexão JDBC deve estar ativa e responder dentro do timeout")
                .isTrue()

            val metaData = connection.metaData
            println("==================================================")
            println(" Testcontainers PostgreSQL Conectado com Sucesso! ")
            println(" Banco de Dados: ${metaData.databaseProductName}")
            println(" Versão do Banco: ${metaData.databaseProductVersion}")
            println(" Driver JDBC: ${metaData.driverName}")
            println(" JDBC URL Ativa: ${metaData.url}")
            println("==================================================")

            assertThat(metaData.databaseProductName).isEqualToIgnoringCase("PostgreSQL")
        }
    }

    @Test
    @DisplayName("Deve validar a tabela customers criada pela migração Flyway")
    fun `deve verificar que a migracao Flyway foi aplicada com sucesso`() {
        dataSource.connection.use { connection ->
            val statement = connection.createStatement()
            val resultSet = statement.executeQuery("SELECT COUNT(*) FROM customers")

            assertThat(resultSet.next()).isTrue()
            val totalRegistros = resultSet.getInt(1)

            println("Total de clientes inseridos pelo Flyway: $totalRegistros")
            assertThat(totalRegistros).isEqualTo(2)
        }
    }
}

5. Executando e Validando com ./gradlew integrationTest

Com a suíte configurada, agora podemos executar nossos testes no terminal através do Gradle Wrapper.

1. Executando Estritamente a Suíte de Integração

Para rodar apenas os testes de integração (subindo o container PostgreSQL efêmero sem executar os testes unitários):

1
./gradlew integrationTest

Na saída do terminal, você observará o log do Testcontainers gerenciando o ciclo de vida do container:

 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
> Task :compileKotlin
> Task :compileIntegrationTestKotlin
> Task :processIntegrationTestResources
> Task :integrationTest

DatabaseIntegrationTest > Deve provisionar container PostgreSQL real e validar conexão ativa STANDARD_OUT
    12:35:10.420 [main] INFO  tc.postgres:17-alpine -- Creating container for image: postgres:17-alpine
    12:35:10.512 [main] INFO  tc.postgres:17-alpine -- Container postgres:17-alpine is starting: 8d2b9ef...
    12:35:11.834 [main] INFO  tc.postgres:17-alpine -- Container postgres:17-alpine started in PT1.414S
    12:35:12.100 [main] INFO  org.flywaydb.core.internal.command.DbMigrate -- Migrating schema "public" to version "1 - create customers table"
    ==================================================
     Testcontainers PostgreSQL Conectado com Sucesso! 
     Banco de Dados: PostgreSQL
     Versão do Banco: 17.2
     Driver JDBC: PostgreSQL JDBC Driver
     JDBC URL Ativa: jdbc:postgresql://localhost:32768/testdb
    ==================================================
    DatabaseIntegrationTest > Deve provisionar container PostgreSQL real e validar conexão ativa PASSED

DatabaseIntegrationTest > Deve validar a tabela customers criada pela migração Flyway STANDARD_OUT
    Total de clientes inseridos pelo Flyway: 2
    DatabaseIntegrationTest > Deve validar a tabela customers criada pela migração Flyway PASSED

BUILD SUCCESSFUL in 4s
4 actionable tasks: 4 executed

2. Executando Apenas os Testes Unitários

Para um ciclo de feedback ultra-rápido durante o desenvolvimento de código isolado (sem inicializar o Docker):

1
./gradlew test

3. Executando a Validação Completa do Projeto

Quando executamos a task padrão check ou build, o Gradle executa ambas as suítes respeitando a ordem definida (shouldRunAfter(test)):

1
./gradlew check

O Gradle primeiro executa a task test (testes unitários). Se todos passarem, ele prossegue para integrationTest. Se qualquer teste falhar, o build é interrompido imediatamente.

4. Pulando a Suíte de Integração em Builds Rápidos

Se você deseja executar o build completo mas pular os testes que dependem de contêineres Docker, use a flag -x:

1
./gradlew build -x integrationTest

5. Localização dos Relatórios Gerados

Ao término da execução, consulte os relatórios HTML detalhados gerados em diretórios independentes:

  • Relatório de Testes Unitários: build/reports/tests/test/index.html
  • Relatório de Testes de Integração: build/reports/tests/integrationTest/index.html

6. Resolução de Problemas Comuns (Troubleshooting)

Erro: Could not find a valid Docker environment

  • Causa: O daemon do Docker não está rodando ou o processo do Gradle não possui permissões no arquivo de socket /var/run/docker.sock.
  • Solução:
    1. Inicie o serviço com sudo systemctl start docker (no Linux) ou abra o Docker Desktop.
    2. Garanta permissões com sudo usermod -aG docker $USER.
    3. No caso de Podman, confirme que export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock" está configurado no seu terminal.

Erro: TimeoutException: Timed out waiting for container to start

  • Causa: O download da imagem do PostgreSQL demorou mais que o timeout padrão do Testcontainers (comum na primeira execução com conexões lentas).
  • Solução: Faça o pull manual prévio da imagem utilizada:
    1
    
    docker pull postgres:17-alpine

Otimização: Reutilização de Contêineres no Desenvolvimento Local

Durante o desenvolvimento no dia a dia, subir e derrubar o container a cada execução de teste pode consumir tempo desnecessário. O Testcontainers permite reutilizar contêineres entre execuções locais:

  1. Crie o arquivo ~/.testcontainers.properties no seu diretório home:

    1
    
    testcontainers.reuse.enable=true
  2. Na sua classe de teste Kotlin, adicione .withReuse(true) ao container:

    1
    2
    3
    4
    5
    
    val postgres = PostgreSQLContainer("postgres:17-alpine")
        .withDatabaseName("testdb")
        .withUsername("testuser")
        .withPassword("secret")
        .withReuse(true)

Com isso, o container permanecerá em execução em segundo plano entre os disparos de ./gradlew integrationTest, acelerando a inicialização para menos de 1 segundo!

Conteúdo