Resposta rápida: use Testcontainers com Kotlin quando seu teste precisa validar o comportamento real do PostgreSQL, Redis, Kafka ou outro serviço de infraestrutura — não apenas uma interface simulada. A biblioteca inicia um container descartável durante a suíte, entrega host e porta dinâmicos para a aplicação e remove o ambiente ao final. Para backend Kotlin, a combinação mais segura é JUnit 5 + Docker + migrations reais + um container compartilhado por classe ou suíte, mantendo cada teste isolado por transação ou limpeza explícita.
Essa abordagem evita um problema frequente: testes que passam com H2 ou mocks, mas falham em produção por causa de SQL, constraints, tipos, índices, timezone ou comportamento transacional diferente. Neste guia, você vai configurar Testcontainers com PostgreSQL, integrar a infraestrutura a projetos Spring Boot e Ktor, preparar o CI e decidir quando um teste com container realmente vale o custo.
O que é Testcontainers?
Testcontainers é uma biblioteca que controla containers Docker a partir dos testes. Em vez de depender de um banco instalado manualmente na máquina ou de um serviço compartilhado entre pessoas desenvolvedoras, a própria suíte declara a infraestrutura necessária.
Um teste pode fazer este ciclo:
- iniciar uma imagem oficial do PostgreSQL;
- aguardar o banco aceitar conexões;
- obter a URL JDBC, o usuário e a senha gerados para aquele container;
- executar migrations com Flyway ou Liquibase;
- rodar o teste contra o banco real;
- destruir o container quando a suíte termina.
O ganho não é “testar o Docker”. O ganho é testar sua aplicação contra a mesma categoria de tecnologia usada em produção, com ambiente reproduzível. Isso complementa — e não substitui — os testes unitários com JUnit 5 e MockK.
Quando usar Testcontainers — e quando não usar
Testcontainers faz sentido para validar fronteiras onde um mock esconde detalhes importantes:
- repositories com SQL, JPA/Hibernate, Exposed ou jOOQ;
- migrations e constraints do PostgreSQL;
- serialização de mensagens para Kafka ou RabbitMQ;
- TTL, locks e estruturas reais do Redis;
- integração com LocalStack, MinIO, Elasticsearch ou outros serviços;
- consultas que dependem de extensões, tipos ou funções específicas do banco;
- fluxo completo de uma API com persistência.
Não coloque container em todo teste. Uma função que calcula desconto, valida um CPF sintético ou transforma um DTO continua sendo melhor testada em memória, rapidamente. A pirâmide saudável costuma ter muitos testes unitários, uma camada menor de testes de integração e poucos testes ponta a ponta.
| Tipo de teste | Infraestrutura | Velocidade | Melhor uso |
|---|---|---|---|
| Unitário | Nenhuma ou mocks | Muito alta | Regras de negócio e transformações |
| Integração com Testcontainers | Serviço real isolado | Média | Banco, fila, cache e adapters |
| Ponta a ponta | Aplicação e dependências completas | Mais baixa | Jornadas críticas do produto |
Dependências no Gradle Kotlin DSL
A documentação oficial mantém uma lista de módulos do Testcontainers. Prefira importar o BOM para manter versões compatíveis e ajuste a versão conforme o catálogo do seu projeto.
// build.gradle.kts
dependencies {
testImplementation(platform("org.testcontainers:testcontainers-bom:<versao-atual>"))
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.postgresql:postgresql")
}
tasks.test {
useJUnitPlatform()
}
Se o projeto usa version catalog, coloque os aliases no libs.versions.toml em vez de espalhar versões pelo build. Em uma base maior, essa disciplina combina com as práticas do guia de Gradle com Kotlin.
Você também precisa de um runtime de containers disponível. Docker Desktop, Docker Engine, Rancher Desktop e alternativas compatíveis podem funcionar, mas a equipe deve validar o ambiente escolhido. O teste não deve depender de um PostgreSQL instalado fora do projeto.
Primeiro teste com PostgreSQL e JUnit 5
O módulo JUnit Jupiter oferece as anotações @Testcontainers e @Container. Um container declarado em companion object com @JvmStatic é estático para o JUnit e pode ser reutilizado por todos os métodos da classe.
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.sql.DriverManager
@Testcontainers
class ProdutoRepositoryIntegrationTest {
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer("postgres:17-alpine")
.withDatabaseName("catalogo_test")
.withUsername("test")
.withPassword("test")
}
@Test
fun `deve inserir e consultar produto`() {
DriverManager.getConnection(
postgres.jdbcUrl,
postgres.username,
postgres.password
).use { connection ->
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE TABLE produto (
id BIGSERIAL PRIMARY KEY,
nome TEXT NOT NULL,
preco NUMERIC(12, 2) NOT NULL CHECK (preco >= 0)
)
""".trimIndent()
)
statement.executeUpdate(
"INSERT INTO produto (nome, preco) VALUES ('Teclado', 349.90)"
)
statement.executeQuery("SELECT COUNT(*) FROM produto").use { result ->
result.next()
assertEquals(1, result.getInt(1))
}
}
}
}
}
A imagem está fixada em uma versão explícita. Evite postgres:latest: uma atualização silenciosa pode mudar o resultado da suíte sem qualquer alteração no repositório. Idealmente, use a mesma versão principal adotada em produção.
O exemplo cria a tabela manualmente apenas para mostrar o ciclo completo. Em um projeto real, execute as mesmas migrations da aplicação.
Execute migrations reais no teste
Se a produção usa Flyway, o teste deve usar Flyway. Assim, você valida não só o repository, mas também se o schema pode ser criado do zero.
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.BeforeAll
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer("postgres:17-alpine")
.withDatabaseName("app_test")
.withUsername("test")
.withPassword("test")
@BeforeAll
@JvmStatic
fun migrarBanco() {
Flyway.configure()
.dataSource(
postgres.jdbcUrl,
postgres.username,
postgres.password
)
.locations("classpath:db/migration")
.load()
.migrate()
}
}
Esse teste encontra erros que uma simulação em memória não encontra: migration fora de ordem, SQL incompatível, índice com sintaxe incorreta, extensão ausente e constraint divergente do modelo Kotlin. Para revisar o restante da camada de persistência, veja também o tutorial de Kotlin com PostgreSQL no backend.
Testcontainers com Kotlin e Spring Boot
Em Spring Boot, a aplicação precisa receber as propriedades do container antes de montar o ApplicationContext. Uma opção amplamente compatível é usar @DynamicPropertySource.
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
@Testcontainers
@SpringBootTest
class PedidoRepositoryIntegrationTest {
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer("postgres:17-alpine")
.withDatabaseName("pedidos_test")
.withUsername("test")
.withPassword("test")
@DynamicPropertySource
@JvmStatic
fun databaseProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
@Test
fun `contexto deve iniciar com banco real`() {
// injete o repository e valide o comportamento observado
}
}
Versões modernas do Spring Boot também oferecem integração de service connections, reduzindo configuração manual em alguns cenários. Confira a documentação da versão usada pelo projeto antes de escolher a anotação, porque nem todas as bases corporativas estão na mesma geração do framework.
Evite carregar a aplicação inteira quando você só precisa testar JPA. Um slice test ou uma configuração focada no repository costuma ser mais rápido. Use @SpringBootTest quando o objetivo realmente inclui wiring, transações, serialização e comportamento integrado. O guia de Kotlin com Spring Boot ajuda a separar controller, service e repository antes de definir o escopo do teste.
Testcontainers com Ktor
Ktor não impõe uma forma única de injetar configuração. Isso facilita criar a aplicação de teste passando a URL do container explicitamente.
data class DatabaseConfig(
val jdbcUrl: String,
val username: String,
val password: String
)
class AppIntegrationTest {
companion object {
val postgres = PostgreSQLContainer("postgres:17-alpine")
.withDatabaseName("ktor_test")
.withUsername("test")
.withPassword("test")
@JvmStatic
@BeforeAll
fun startContainer() {
postgres.start()
}
@JvmStatic
@AfterAll
fun stopContainer() {
postgres.stop()
}
}
@Test
fun `POST deve persistir e retornar recurso`() = testApplication {
environment {
config = MapApplicationConfig(
"database.jdbcUrl" to postgres.jdbcUrl,
"database.username" to postgres.username,
"database.password" to postgres.password
)
}
application {
module()
}
val response = client.post("/produtos") {
contentType(ContentType.Application.Json)
setBody("""{"nome":"Mouse","preco":199.90}""")
}
assertEquals(HttpStatusCode.Created, response.status)
}
}
Você pode controlar o ciclo manualmente ou criar uma extensão JUnit reutilizável. O mais importante é não esconder host e porta em variáveis globais fixas: o Testcontainers publica portas dinâmicas justamente para permitir isolamento e execução paralela. Para estruturar a API, consulte o guia de APIs REST com Ktor e Kotlin.
Como manter os testes isolados
Compartilhar um container reduz tempo de startup, mas cria risco de vazamento de estado entre testes. Há quatro estratégias comuns:
1. Transação com rollback
Cada teste abre uma transação que é revertida no final. É rápido, mas pode não representar fluxos com múltiplas conexões, eventos assíncronos ou commits intermediários.
2. Truncate entre testes
Limpe as tabelas com TRUNCATE ... RESTART IDENTITY CASCADE. Funciona bem, desde que a ordem e as permissões estejam corretas.
3. Schema ou database por teste
Oferece isolamento forte, mas aumenta complexidade e custo. Pode ser útil em suítes paralelas.
4. Dados únicos
Gere identificadores e e-mails únicos em cada caso. Isso reduz colisões, porém não substitui limpeza quando o teste depende de contagem total ou estado inicial vazio.
Uma regra prática: compartilhe o processo do PostgreSQL, não o estado lógico. Cada teste deve conseguir rodar sozinho, em outra ordem e repetidamente.
Performance sem sacrificar confiança
O primeiro teste costuma ser mais lento porque o runtime precisa baixar a imagem e iniciar o serviço. Depois, o cache local reduz bastante o custo. Para manter a suíte útil:
- compartilhe o container entre testes relacionados;
- use imagens pequenas e versões fixas;
- não recrie o
ApplicationContextsem necessidade; - separe testes unitários e de integração em tarefas Gradle;
- execute integrações em paralelo somente após garantir isolamento;
- evite
Thread.sleep; aguarde condições observáveis; - meça o tempo por classe antes de aplicar otimizações complexas.
Você pode criar uma source set ou usar tags do JUnit 5:
@Tag("integration")
class ProdutoRepositoryIntegrationTest
E filtrar no Gradle conforme o pipeline. O guia de CI/CD para Kotlin mostra como organizar gates rápidos e verificações mais completas.
Rodando Testcontainers no CI
O runner precisa ter acesso a um runtime compatível com containers. Em runners Linux com Docker, valide três pontos:
- o usuário do job consegue acessar o daemon;
- há espaço em disco para imagens e volumes temporários;
- o timeout da etapa considera download e startup na primeira execução.
Não fixe a aplicação em localhost:5432. Use sempre postgres.jdbcUrl, porque a porta publicada é dinâmica. Também não persista credenciais de teste: usuário e senha podem ser valores efêmeros definidos no próprio teste.
Cache de imagem pode acelerar o pipeline, mas não transforme cache em pré-requisito. Um runner limpo deve conseguir executar a suíte do zero. Em ambientes que não oferecem Docker, avalie um serviço de banco provisionado pelo CI; porém, você perde parte do isolamento e da portabilidade que motivam o Testcontainers.
Erros comuns
Usar H2 como “PostgreSQL rápido”
H2 pode ser útil em cenários específicos, mas não é PostgreSQL. Tipos JSON, arrays, extensões, regras de case, locking e SQL podem divergir. Se a produção usa PostgreSQL, um teste de integração importante deve conversar com PostgreSQL.
Criar um container por método
Isso torna a suíte desnecessariamente lenta. Comece com um container por classe ou conjunto de testes e isole os dados.
Usar latest
A build deixa de ser reproduzível. Fixe a versão da imagem e atualize por pull request, com revisão.
Testar implementação em vez de comportamento
Não verifique cada chamada interna. Grave dados, execute a operação pública e observe o resultado persistido, o status HTTP ou a mensagem publicada.
Esquecer migrations
Criar tabelas manualmente dentro do teste pode fazer a suíte validar um schema diferente do deploy. Use Flyway ou Liquibase sempre que esses mecanismos forem a fonte de verdade.
Reutilizar dados sem limpeza
O teste passa isoladamente e falha na suíte completa. Nomes únicos ajudam, mas limpeza ou rollback continuam necessários.
Checklist para um setup confiável
Antes de considerar a integração pronta, confira:
- imagem com versão fixa e próxima da produção;
- dependências gerenciadas por BOM ou version catalog;
- migrations reais executadas no container;
- URL, usuário e senha obtidos dinamicamente;
- isolamento entre métodos de teste;
- container compartilhado no escopo adequado;
- suíte funcionando em máquina limpa e no CI;
- logs suficientes para diagnosticar falhas de startup;
- nenhum segredo real embutido nos testes;
- testes unitários continuam separados e rápidos.
Testcontainers, mocks ou Docker Compose?
As três ferramentas têm funções diferentes.
- Mocks isolam uma unidade e permitem testar regras sem infraestrutura.
- Testcontainers cria dependências reais sob controle do teste, com ciclo de vida automatizado.
- Docker Compose sobe um ambiente composto, útil para desenvolvimento local ou testes ponta a ponta, mas normalmente exige mais coordenação externa.
Para um repository PostgreSQL, Testcontainers costuma ser a escolha mais direta. Para uma regra de domínio, use mock ou fake. Para validar uma jornada com API, banco, fila e worker juntos, Compose ou uma composição de múltiplos containers pode fazer sentido. Veja também o guia de Docker para Kotlin.
Perguntas frequentes
Testcontainers funciona com Kotlin ou apenas com Java?
Funciona normalmente com Kotlin porque a biblioteca roda na JVM. Algumas APIs Java exigem detalhes como @JvmStatic em membros de companion object, mas a integração com JUnit 5, Spring Boot, Ktor e Gradle é direta.
Preciso instalar PostgreSQL localmente?
Não. Você precisa de um runtime de containers compatível e da imagem declarada pelo teste. O próprio container fornece o PostgreSQL.
Testcontainers deixa o CI muito lento?
Pode deixar se cada teste iniciar um container novo ou se o runner baixar imagens sempre. Com containers compartilhados, imagens em cache e escopo de testes bem definido, o custo costuma ser aceitável para a confiança obtida.
Posso testar Redis, Kafka e outros serviços?
Sim. O projeto oferece módulos e containers genéricos para diversas tecnologias. Comece por uma dependência crítica, estabilize o padrão de isolamento e só depois expanda a estratégia.
Devo substituir todos os mocks por containers?
Não. Mocks continuam úteis para regras unitárias, falhas controladas e dependências caras. Use container quando o comportamento real da infraestrutura faz parte do que você precisa provar.
Conclusão
Testcontainers com Kotlin resolve uma lacuna importante entre testes rápidos e produção real. Ao iniciar PostgreSQL, Redis, Kafka ou outra dependência sob demanda, sua suíte consegue validar SQL, migrations, drivers, serialização e configuração sem depender de ambientes compartilhados.
O setup mais equilibrado usa JUnit 5, uma imagem versionada, migrations reais, container compartilhado e isolamento de dados. Em Spring Boot, injete propriedades dinamicamente ou use a integração da versão adotada pelo projeto. Em Ktor, passe a configuração do container para a aplicação de teste de forma explícita.
Comece por um repository que já causou bugs ou por uma migration crítica. Não tente converter toda a suíte de uma vez. Quando o primeiro teste rodar igual na máquina local e no CI, transforme o padrão em uma pequena infraestrutura reutilizável para o time. Com isso, Kotlin deixa de apenas “simular” o backend e passa a provar que ele funciona contra os serviços que realmente sustentam a aplicação.