Testcontainers em Go resolve uma dúvida que aparece quando os testes unitários deixam de ser suficientes: como validar código que depende de PostgreSQL, Redis, RabbitMQ, Kafka, S3 local, OpenSearch ou outro serviço externo sem transformar o ambiente de teste em uma gambiarra frágil? Mock ajuda a testar regra de negócio isolada, mas não garante que a query SQL compila, que a migration sobe, que o índice existe, que o TTL do Redis funciona ou que o consumer realmente confirma uma mensagem do jeito esperado.
Em projetos Go, essa fronteira costuma aparecer rápido. A linguagem incentiva interfaces pequenas, go test ./... rápido e código explícito. Só que backends reais dependem de infraestrutura. Uma API com Go e PostgreSQL, um worker com Redis, um serviço com mensageria em Go ou um endpoint que precisa testar idempotência não pode confiar apenas em fake em memória. Em algum momento, você precisa rodar o teste contra um serviço parecido com produção.
Testcontainers cria containers Docker sob demanda durante o teste, expõe a porta real, espera o serviço ficar pronto e devolve a connection string para o código Go. Quando o teste termina, o container é removido. O resultado é um meio-termo forte: mais realista que mock, mais automatizado que ambiente compartilhado e mais reproduzível que depender de um banco instalado na máquina de cada pessoa.
Quando usar Testcontainers em Go
Use Testcontainers quando o comportamento que você quer validar depende de um serviço externo real. Alguns exemplos comuns:
- Queries SQL geradas por
sqlcou escritas comdatabase/sqlepgx. - Migrations em Go aplicadas antes do deploy.
- Constraints, índices, transações, locks e
ON CONFLICTno PostgreSQL. - TTL, scripts Lua, locks distribuídos e tipos específicos do Redis.
- Publicação e consumo em brokers como RabbitMQ, Kafka ou NATS.
- APIs que precisam testar fluxo completo entre handler HTTP, banco e fila.
Não use Testcontainers para tudo. Funções puras, validação de input, cálculo, parser, formatação e regra de negócio isolada continuam melhores com testes unitários simples e rápidos. O padrão saudável é uma pirâmide: muitos unit tests, alguns testes de integração com containers e poucos testes end-to-end de sistema inteiro.
Se você ainda está organizando a base, leia também testes em Go e testes de tabela em Go. Testcontainers não substitui esses padrões; ele entra quando o teste precisa provar que seu código conversa corretamente com infraestrutura real.
Instalação mínima
O pacote principal é github.com/testcontainers/testcontainers-go. Para serviços populares, prefira os módulos oficiais, porque eles já trazem helpers de imagem, variáveis de ambiente e readiness.
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
go get github.com/testcontainers/testcontainers-go/modules/redis
go get github.com/jackc/pgx/v5/pgxpool
O requisito operacional é ter Docker disponível no ambiente onde o teste roda. Em máquina local, normalmente basta Docker Desktop, Colima, Rancher Desktop ou Docker Engine. Em CI, você precisa escolher um runner que permita containers, configurar Docker-in-Docker com cuidado ou usar um serviço de Docker remoto.
Antes de empilhar containers em todos os pacotes, valide um teste simples no seu CI. A maior parte das dores de Testcontainers não está na API Go; está em rede, permissões, cache de imagens e limites de CPU/memória no runner.
Exemplo com PostgreSQL
Um teste de integração útil deve criar o banco, aplicar schema, rodar o código real e verificar o resultado por uma query independente. O exemplo abaixo usa o módulo de PostgreSQL e pgxpool.
package userrepo_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestUserRepositoryCreate(t *testing.T) {
ctx := context.Background()
container, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("app"),
postgres.WithUsername("app"),
postgres.WithPassword("secret"),
postgres.WithInitScripts("testdata/schema.sql"),
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
)
if err != nil {
t.Fatalf("start postgres: %v", err)
}
t.Cleanup(func() { _ = container.Terminate(ctx) })
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
db, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect postgres: %v", err)
}
t.Cleanup(db.Close)
repo := NewUserRepository(db)
user, err := repo.Create(ctx, "[email protected]", "Ana")
if err != nil {
t.Fatalf("create user: %v", err)
}
var count int
err = db.QueryRow(ctx, "SELECT count(*) FROM users WHERE id = $1", user.ID).Scan(&count)
if err != nil {
t.Fatalf("count users: %v", err)
}
if count != 1 {
t.Fatalf("users count = %d, want 1", count)
}
}
O arquivo testdata/schema.sql pode ser um schema pequeno ou o resultado das suas migrations. Em projetos com sqlc em Go, esse teste é especialmente valioso: ele valida a query gerada, o driver, o schema real e a transação de ponta a ponta.
Migrations no teste: use o mesmo caminho do deploy
Uma armadilha comum é criar um schema simplificado só para teste. Parece mais rápido, mas abre uma diferença perigosa entre teste e produção. Se o deploy aplica migrations, o teste de integração também deve aplicar migrations. Assim você testa o contrato real: tabelas, índices, constraints, enums, funções e permissões.
Um desenho prático:
- Suba o container PostgreSQL limpo.
- Rode o mesmo migrator usado no deploy, apontando para a connection string do container.
- Insira fixtures mínimas para o cenário.
- Execute o código Go real.
- Verifique estado final com query clara.
Você pode usar ferramentas como golang-migrate, tern, atlas, goose ou um migrator interno. O importante é que o teste falhe se uma migration quebrar. Isso evita o clássico “passou no mock, falhou no banco”.
Fixtures sem virar bagunça
Testes de integração morrem quando as fixtures ficam grandes demais. Prefira dados pequenos, nomeados e próximos do teste. Use builders simples em Go ou arquivos SQL por cenário. Evite importar dump de produção, porque isso deixa o teste lento, frágil e difícil de revisar.
Uma boa fixture responde a uma pergunta específica: “existe um usuário ativo com plano premium”, “há uma cobrança pendente”, “o evento já foi processado”. Se você precisa de dezenas de registros para testar uma regra, talvez a regra esteja acoplada demais ao banco ou o teste esteja tentando validar muitos comportamentos ao mesmo tempo.
Também limpe o estado entre testes. Três opções comuns:
- Criar um container por teste: mais isolado, porém mais lento.
- Criar um container por pacote e truncar tabelas: mais rápido, exige disciplina.
- Usar transação por teste e rollback: eficiente para banco, mas não cobre efeitos externos fora da transação.
Para começar, container por teste é mais simples. Quando a suíte crescer, meça antes de otimizar.
Testando Redis com comportamento real
Redis é outro caso em que mock engana. TTL, SET NX, scripts Lua, streams e concorrência têm detalhes que aparecem apenas no servidor real. Um teste com container pode validar locks, cache e rate limiting sem depender de um Redis compartilhado. O exemplo abaixo usa o módulo github.com/testcontainers/testcontainers-go/modules/redis.
container, err := redis.Run(ctx, "redis:7-alpine")
if err != nil {
t.Fatalf("start redis: %v", err)
}
t.Cleanup(func() { _ = container.Terminate(ctx) })
addr, err := container.ConnectionString(ctx)
if err != nil {
t.Fatalf("redis addr: %v", err)
}
Depois conecte com o client usado pela aplicação, como go-redis, e teste o comportamento que importa. Se você tem rate limiting em Go com Redis, por exemplo, valide estouro de limite, expiração da janela e concorrência. Esse é o tipo de bug que raramente aparece em mock.
CI/CD: deixe previsível antes de deixar rápido
No CI, o objetivo inicial é previsibilidade. Puxe imagens oficiais, use tags fixas como postgres:16-alpine, limite paralelismo e registre logs quando o container falhar. Em pipelines com pouco recurso, rodar todos os testes de integração em paralelo pode derrubar o runner e gerar falhas intermitentes.
Boas práticas:
- Separe testes de integração com build tag, como
//go:build integration, se eles forem pesados. - Rode
go test ./...sempre ego test -tags=integration ./...em pull requests principais ou antes do deploy. - Use timeout explícito no comando de teste.
- Cacheie camadas Docker quando o CI permitir.
- Evite imagens
latest; fixe versão.
Um comando simples para pipeline:
go test -count=1 -timeout=10m -tags=integration ./...
Se o projeto é pequeno, pode rodar tudo junto. Se o repositório já tem muitos pacotes, separar unitário de integração ajuda a manter feedback rápido para mudanças simples.
Erros comuns
O primeiro erro é testar detalhe demais. Teste o contrato entre sua aplicação e o serviço, não a implementação interna do container. Você não precisa provar que PostgreSQL funciona; precisa provar que suas migrations, queries e transações funcionam no PostgreSQL.
O segundo é ignorar readiness. Um container criado não significa serviço pronto. Use waits específicos: log, porta, healthcheck ou query. Falhas intermitentes quase sempre vêm de teste que tenta conectar antes do banco inicializar.
O terceiro é depender de ordem entre testes. Cada teste deve preparar seu estado. Se TestB só passa depois de TestA, a suíte vai quebrar com paralelismo, cache ou execução seletiva.
O quarto é esconder erro de infraestrutura como skip automático. Se Docker não está disponível no CI onde testes de integração deveriam rodar, o pipeline precisa falhar. Skip pode ser aceitável localmente, mas em ambiente de validação ele mascara regressão.
O quinto é esquecer carreira. Muitas vagas Go backend e vagas Go DevOps/SRE citam testes, PostgreSQL, Docker, CI/CD, Redis, Kubernetes e observabilidade. Saber explicar Testcontainers em entrevista mostra maturidade prática: você não apenas escreve handler, você prova que ele conversa com dependências reais. Para comparar como esse tema aparece em outras stacks brasileiras, acompanhe também o portal Python Dev Brasil reúne vagas Python e dados no Brasil.
Checklist para adotar sem dor
Comece por um fluxo crítico e pequeno. Um teste de criação de usuário com PostgreSQL, uma migration importante, um lock Redis ou um consumer simples já traz valor. Depois expanda para cenários onde bug em infraestrutura custa caro: idempotência, transação, retry, concorrência e rollback.
Checklist prático:
- Use imagem com versão fixa.
- Aplique migrations reais.
- Mantenha fixtures mínimas.
- Espere readiness explicitamente.
- Feche conexões e termine containers com
t.Cleanup. - Separe testes pesados com tag se necessário.
- Rode no CI antes do deploy.
- Meça duração antes de otimizar.
Testcontainers não torna todos os testes perfeitos, mas fecha uma lacuna importante entre unit tests e produção. Em Go, onde simplicidade e feedback rápido importam, ele funciona melhor quando usado com parcimônia: poucos testes de integração bem escolhidos, cobrindo contratos que mocks não conseguem garantir.
O próximo passo natural é combinar este guia com Docker para Go, TDD e CI/CD em Go, migrations em Go e observabilidade em Go. Assim seus testes deixam de ser apenas uma etapa local e passam a fazer parte do caminho seguro entre código, pipeline e produção.