Testes de integração com Testcontainers em Go
Como subir PostgreSQL, Redis ou Kafka reais em contêineres Docker durante os testes, de forma programática, isolada e descartável.
Publicada em · 8 min de leitura
Durante anos, desenvolvedores backend enfrentaram um dilema clássico ao testar código que interage com bancos de dados e mensageria:
- Usar mocks de SQL (como
go-sqlmock): Os testes rodam rápido, mas não testam o banco de verdade. Mocks apenas validam se a string da query bate caractere por caractere; eles não descobrem se você cometeu um erro de sintaxe SQL, se violou uma chave estrangeira ou se o tipoJSONBquebrou. - Substituir por SQLite em memória: SQLite não suporta recursos cruciais do PostgreSQL (como arrays, UUID nativo, cláusulas
ON CONFLICT DO UPDATE, índices parciais e controle de concorrência com MVCC). - Manter um banco de dados local fixo compartilhado: Gera conflitos de concorrência entre desenvolvedores, acúmulo de dados sujos entre execuções e o clássico erro de porta já em uso (
bind: address already in use: 5432).
A solução definitiva adotada pela indústria moderna de software é o Testcontainers para Go (testcontainers-go).
Esta aula ensina como utilizar o Testcontainers para instanciar contêineres Docker reais (PostgreSQL, Redis, Kafka) de forma totalmente programática, isolada e efêmera, diretamente dentro do ciclo de vida dos seus testes em Go.
O Mapa da Aula
| Parte | Seção | O que você vai dominar |
|---|---|---|
| 1. Fundamentos | 1 e 2 | O que é o Testcontainers, por que portas dinâmicas são vitais e o papel do Moby Ryuk |
| 2. O Módulo Postgres | 3 e 4 | Instanciação com modules/postgres, extração da Connection String e estratégias de espera (Wait Strategies) |
| 3. Ciclo de Vida com Suíte | 5 e 6 | Integração com suite.Suite, isolamento de dados com SetupTest e limpeza garantida com Terminate |
| 4. Código de Produção | 7 e 8 | Implementação prática de um repositório com pgxpool e PostgreSQL 16 real |
| 5. Práticas Corporativas | 9 e 10 | Isolando testes rápidos e lentos com testing.Short(), execução em CI e checklist de qualidade |
Pré-requisitos recomendados: Suíte de Testes com Testify e Mocks e Dublês de Teste.
Parte 1: A Arquitetura do Testcontainers para Go
1. Como o Testcontainers funciona sob o capô
O Testcontainers é uma biblioteca que se comunica diretamente com o Docker Engine API local ou do servidor de CI através do socket do Docker (/var/run/docker.sock):
O ciclo operacional segue passos rigorosos:
- Ao iniciar a suíte de testes, o Go solicita a criação de um contêiner baseado em uma imagem oficial (ex:
postgres:16-alpine). - Portas Dinâmicas e Aleatórias: O Testcontainers nunca faz bind de portas fixas no host. Em vez de usar a porta
5432, o Docker atribui uma porta alta livre aleatória (por exemplo,55001 -> 5432). Isso permite rodar múltiplos testes paralelos e múltiplos pipelines de CI na mesma máquina sem nenhum conflito de porta! - O Go aguarda o banco sinalizar que está pronto para receber conexões.
- O teste executa queries SQL reais sobre o banco de dados em memória do contêiner.
- Ao final, o contêiner é destruído, garantindo que nenhum lixo de dados sobreviva entre suítes.
2. O Guardião contra Contêineres Órfãos: Moby Ryuk
Um dos maiores temores ao rodar contêineres em testes é o vazamento de recursos: e se o desenvolvedor cancelar o teste no meio com Ctrl+C ou se o processo de Go for morto abruptamente com um panic ou kill -9? O contêiner ficaria rodando indefinidamente consumindo memória da máquina?
O Testcontainers resolve isso com um componente engenhoso chamado Moby Ryuk (testcontainers/ryuk):
- O Ryuk é um contêiner auxiliar minúsculo que abre uma conexão socket bidirecional com o processo do seu teste em Go.
- Se a conexão com o teste Go cair por qualquer motivo (mesmo um encerramento forçado do sistema), o Ryuk detecta o encerramento do cliente e destrói imediatamente todos os contêineres e volumes criados durante aquela sessão.
Parte 2: O Módulo Oficial modules/postgres
O ecossistema testcontainers-go disponibiliza módulos prontos e especializados para as ferramentas mais populares de infraestrutura. Para o PostgreSQL:
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
3. Instanciando o Contêiner
Para subir um PostgreSQL em Go:
ctx := context.Background()
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
)
if err != nil {
log.Fatalf("falha ao iniciar container postgres: %v", err)
}
defer func() {
_ = pgContainer.Terminate(ctx) // Destrói o container no final
}()
4. Obtendo a Connection String Dinâmica
Como a porta mapeada pelo Docker é aleatória, você nunca deve escrever localhost:5432 no seu código de teste. O próprio objeto do contêiner fornece o método ConnectionString:
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
// Retorna algo como: postgres://testuser:testpass@localhost:55001/testdb?sslmode=disable
Você passa essa string diretamente para o database/sql ou para o pool de conexões moderno pgxpool.New(ctx, connStr).
Parte 3: Ciclo de Vida com Testify Suite
Subir um contêiner Docker leva entre 1 e 3 segundos. Por essa razão, nunca suba um contêiner para cada função de teste individual.
O padrão arquitetural correto é associar o Testcontainers aos hooks de ciclo de vida de uma Suíte de Testes com Testify:
SetupSuite(): Sobe o contêiner uma única vez, extrai a connection string, executa as migrações (criação de tabelas e índices) e abre o pool de conexões.SetupTest(): Antes de cada método de teste, executa um comando rápidoTRUNCATE TABLEpara limpar os dados inseridos pelo teste anterior, garantindo isolamento total.TearDownSuite(): Fecha o pool de conexões e invocapgContainer.Terminate(ctx)para derrubar o contêiner.
Parte 4: Código Prático Completo de Produção
Abaixo está a implementação real de um repositório de clientes corporativo utilizando o driver de alta performance pgx e testado com PostgreSQL 16 real.
O Código de Domínio e Repositório (cliente.go):
package repository
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Cliente struct {
ID string
Nome string
Email string
CreatedAt time.Time
}
type ClienteRepository struct {
pool *pgxpool.Pool
}
func NovoClienteRepository(pool *pgxpool.Pool) *ClienteRepository {
return &ClienteRepository{pool: pool}
}
func (r *ClienteRepository) Inserir(ctx context.Context, c *Cliente) error {
query := `INSERT INTO clientes (id, nome, email, created_at) VALUES ($1, $2, $3, $4)`
_, err := r.pool.Exec(ctx, query, c.ID, c.Nome, c.Email, c.CreatedAt)
if err != nil {
return fmt.Errorf("erro ao inserir cliente: %w", err)
}
return nil
}
func (r *ClienteRepository) BuscarPorEmail(ctx context.Context, email string) (*Cliente, error) {
query := `SELECT id, nome, email, created_at FROM clientes WHERE email = $1`
var c Cliente
err := r.pool.QueryRow(ctx, query, email).Scan(&c.ID, &c.Nome, &c.Email, &c.CreatedAt)
if err != nil {
return nil, fmt.Errorf("cliente não encontrado: %w", err)
}
return &c, nil
}
A Suíte de Integração com Testcontainers (cliente_test.go):
package repository_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/suite"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"meu-modulo/repository"
)
type ClienteRepositoryTestSuite struct {
suite.Suite
ctx context.Context
container *tcpostgres.PostgresContainer
pool *pgxpool.Pool
repo *repository.ClienteRepository
}
// SetupSuite: executado uma única vez antes de todos os testes
func (s *ClienteRepositoryTestSuite) SetupSuite() {
s.ctx = context.Background()
// 1. Sobe container efêmero de Postgres 16 oficial
pgContainer, err := tcpostgres.Run(s.ctx,
"postgres:16-alpine",
tcpostgres.WithDatabase("testdb"),
tcpostgres.WithUsername("testuser"),
tcpostgres.WithPassword("testpass"),
)
s.Require().NoError(err, "falha ao inicializar container do Postgres")
s.container = pgContainer
// 2. Extrai a connection string dinâmica (porta host atribuída aleatoriamente)
connStr, err := s.container.ConnectionString(s.ctx, "sslmode=disable")
s.Require().NoError(err)
// 3. Conecta via pgxpool
pool, err := pgxpool.New(s.ctx, connStr)
s.Require().NoError(err)
s.pool = pool
// 4. Aguarda estabilização do banco de dados (resiliência ativa)
var pingErr error
for i := 0; i < 15; i++ {
pingErr = s.pool.Ping(s.ctx)
if pingErr == nil {
break
}
time.Sleep(200 * time.Millisecond)
}
s.Require().NoError(pingErr, "banco deve responder ao ping")
// 5. Executa DDL de criação da tabela de produção
ddl := `
CREATE TABLE IF NOT EXISTS clientes (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE NOT NULL,
created_at TIMESTAMP NOT NULL
);`
_, err = s.pool.Exec(s.ctx, ddl)
s.Require().NoError(err)
s.repo = repository.NovoClienteRepository(s.pool)
}
// SetupTest: executado antes de CADA método para garantir tabelas limpas
func (s *ClienteRepositoryTestSuite) SetupTest() {
_, err := s.pool.Exec(s.ctx, "TRUNCATE TABLE clientes;")
s.Require().NoError(err)
}
// TearDownSuite: derruba o container garantindo zero lixo na máquina
func (s *ClienteRepositoryTestSuite) TearDownSuite() {
if s.pool != nil {
s.pool.Close()
}
if s.container != nil {
_ = s.container.Terminate(s.ctx)
}
}
// Teste 1: Inserção e recuperação com banco de dados real
func (s *ClienteRepositoryTestSuite) TestInserirEBuscarPorEmail() {
cliente := &repository.Cliente{
ID: "c-001",
Nome: "Mariana Costa",
Email: "mariana@empresa.com",
CreatedAt: time.Now().Truncate(time.Microsecond),
}
err := s.repo.Inserir(s.ctx, cliente)
s.Require().NoError(err)
encontrado, err := s.repo.BuscarPorEmail(s.ctx, "mariana@empresa.com")
s.Require().NoError(err)
s.Require().NotNil(encontrado)
s.Assert().Equal("c-001", encontrado.ID)
s.Assert().Equal("Mariana Costa", encontrado.Nome)
s.Assert().Equal("mariana@empresa.com", encontrado.Email)
}
// Teste 2: Consulta de registro inexistente
func (s *ClienteRepositoryTestSuite) TestBuscarNaoExistente() {
_, err := s.repo.BuscarPorEmail(s.ctx, "naoexiste@email.com")
s.Require().Error(err)
s.Assert().Contains(err.Error(), "cliente não encontrado")
}
// Runner oficial: permite pular testes lentos de infraestrutura via -short
func TestClienteRepositoryTestSuite(t *testing.T) {
if testing.Short() {
t.Skip("Pulando teste de integração pesado no modo -short")
}
suite.Run(t, new(ClienteRepositoryTestSuite))
}
Parte 5: Práticas Corporativas e CI/CD
9. Separando Testes Rápidos e Lentos com -short
Testes unitários puros devem rodar em milissegundos. Testes de integração com contêineres Docker levam alguns segundos.
Para que o desenvolvedor tenha feedback instantâneo enquanto escreve código no dia a dia, utilizamos a convenção nativa testing.Short():
if testing.Short() {
t.Skip("Pulando teste de integração no modo -short")
}
- No loop rápido de desenvolvimento local:
go test -short ./... # Roda apenas testes unitários em 0.2 segundos - Antes de abrir Pull Request e na esteira de CI/CD:
go test -v ./... # Roda a suíte completa incluindo Testcontainers
10. Checklist de Qualidade com Testcontainers
- Use imagens leves: Prefira sempre imagens Alpine (ex:
postgres:16-alpine,redis:7-alpine) para diminuir drasticamente o tempo de download inicial na esteira de CI. - Reutilize o contêiner na mesma suíte (
SetupSuite): Suba 1 contêiner por suíte de repositório e limpe as tabelas comTRUNCATE TABLEnoSetupTest(). Isso reduz o tempo da suíte de minutos para meros 2 a 3 segundos. - Não dependa de conexões síncronas cegas: Após o contêiner subir, execute um laço de ping com retry antes de rodar os scripts DDL, pois bancos de dados podem levar algumas centenas de milissegundos para concluir a rotina interna de
initdb. - Sempre chame
container.Terminate(ctx)noTearDownSuite: Mesmo com o Ryuk protegendo contra órfãos, liberar a memória imediatamente ao término da suíte mantém a máquina limpa.
Exercício Prático Proposto
- Crie um contêiner efêmero de Redis utilizando o módulo
github.com/testcontainers/testcontainers-go/modules/redis. - Conecte um cliente
go-redis/v9utilizando a connection string dinâmica retornada pelo contêiner. - Implemente uma suíte de testes validando:
- Gravação de chave com tempo de expiração TTL.
- Leitura de chave inexistente retornando
redis.Nil.
- Adicione
t.Skipcasotesting.Short()esteja ativado.