Testes unitários cobrem lógica isolada, mas não garantem que seu código funciona com bancos de dados, caches e filas reais. Mocks simulam comportamento, porém escondem bugs que só aparecem em produção – queries SQL com sintaxe válida mas semântica errada, problemas de encoding no Redis, race conditions no pub/sub.
É exatamente esse gap que o Testcontainers resolve. Ele sobe containers Docker efêmeros durante os testes, com serviços reais, e destrói tudo ao final. Seu pipeline de CI roda com o mesmo PostgreSQL, Redis ou Kafka que existe em produção – sem mocks, sem surpresas.
Se você já domina testes em Go e usa Docker na sua stack, Testcontainers é o próximo passo natural para uma suite de testes confiável.
Por que abandonar mocks para integrações
Mocks são ótimos para isolar unidades de código. Mas para repositórios, caches e filas, eles têm um problema fundamental: você testa contra uma simulação, não contra o serviço real.
Cenários onde mocks falham silenciosamente:
- Query SQL que funciona no mock mas falha no PostgreSQL real por diferença de tipos
- Operação Redis que assume comportamento de TTL que o mock não implementa
- Serialização JSON que passa no mock mas quebra com encoding do banco
- Transações com isolamento que o mock não reproduz
Com Testcontainers, cada teste roda contra o serviço real. Se o teste passa, você tem confiança de que o comportamento funciona em produção.
Instalando o Testcontainers para Go
Adicione o pacote principal e os módulos específicos para os serviços que você usa:
// Pacote principal
go get github.com/testcontainers/testcontainers-go
// Módulos pré-configurados para serviços populares
go get github.com/testcontainers/testcontainers-go/modules/postgres
go get github.com/testcontainers/testcontainers-go/modules/redis
O Testcontainers precisa de Docker rodando na máquina. No CI, qualquer runner com Docker funciona – incluindo GitHub Actions, GitLab CI e Gitea Actions.
Testando com PostgreSQL real
Vamos criar um repositório de usuários e testá-lo contra um PostgreSQL real. Primeiro, o código de produção:
package user
import (
"context"
"database/sql"
"fmt"
"time"
)
// User representa um usuário no sistema.
type User struct {
ID int64
Name string
Email string
CreatedAt time.Time
}
// Repository gerencia operações de usuários no banco.
type Repository struct {
db *sql.DB
}
// NewRepository cria um novo repositório com a conexão fornecida.
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
// Create insere um novo usuário e retorna o ID gerado.
func (r *Repository) Create(ctx context.Context, name, email string) (int64, error) {
var id int64
err := r.db.QueryRowContext(ctx,
`INSERT INTO users (name, email, created_at)
VALUES ($1, $2, NOW())
RETURNING id`,
name, email,
).Scan(&id)
if err != nil {
return 0, fmt.Errorf("erro ao criar usuário: %w", err)
}
return id, nil
}
// GetByEmail busca um usuário pelo email.
func (r *Repository) GetByEmail(ctx context.Context, email string) (*User, error) {
u := &User{}
err := r.db.QueryRowContext(ctx,
`SELECT id, name, email, created_at
FROM users WHERE email = $1`,
email,
).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
if err != nil {
return nil, fmt.Errorf("usuário não encontrado: %w", err)
}
return u, nil
}
Agora, o teste usando Testcontainers:
package user_test
import (
"context"
"database/sql"
"testing"
"time"
_ "github.com/lib/pq"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
// setupPostgres sobe um container PostgreSQL e retorna a conexão.
func setupPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
// Cria o container com configurações específicas
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("falha ao iniciar container postgres: %v", err)
}
// Garante que o container será destruído ao final do teste
t.Cleanup(func() {
if err := pgContainer.Terminate(ctx); err != nil {
t.Logf("falha ao terminar container: %v", err)
}
})
// Obtém a connection string gerada automaticamente
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("falha ao obter connection string: %v", err)
}
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Fatalf("falha ao conectar no banco: %v", err)
}
// Cria a tabela para os testes
_, err = db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`)
if err != nil {
t.Fatalf("falha ao criar tabela: %v", err)
}
return db
}
func TestRepository_Create(t *testing.T) {
db := setupPostgres(t)
repo := NewRepository(db)
ctx := context.Background()
id, err := repo.Create(ctx, "João Silva", "joao@exemplo.com")
if err != nil {
t.Fatalf("erro inesperado ao criar usuário: %v", err)
}
if id <= 0 {
t.Errorf("esperava ID positivo, recebeu %d", id)
}
}
func TestRepository_GetByEmail(t *testing.T) {
db := setupPostgres(t)
repo := NewRepository(db)
ctx := context.Background()
// Cria o usuário primeiro
_, err := repo.Create(ctx, "Maria Santos", "maria@exemplo.com")
if err != nil {
t.Fatalf("erro ao criar usuário: %v", err)
}
// Busca pelo email
user, err := repo.GetByEmail(ctx, "maria@exemplo.com")
if err != nil {
t.Fatalf("erro ao buscar usuário: %v", err)
}
if user.Name != "Maria Santos" {
t.Errorf("nome esperado 'Maria Santos', recebeu '%s'", user.Name)
}
if user.Email != "maria@exemplo.com" {
t.Errorf("email esperado 'maria@exemplo.com', recebeu '%s'", user.Email)
}
}
func TestRepository_DuplicateEmail(t *testing.T) {
db := setupPostgres(t)
repo := NewRepository(db)
ctx := context.Background()
// Cria o primeiro usuário
_, err := repo.Create(ctx, "Ana Costa", "ana@exemplo.com")
if err != nil {
t.Fatalf("erro ao criar primeiro usuário: %v", err)
}
// Tenta criar com o mesmo email -- deve falhar
_, err = repo.Create(ctx, "Outro Nome", "ana@exemplo.com")
if err == nil {
t.Error("esperava erro ao criar usuário com email duplicado")
}
}
Cada teste recebe seu próprio container PostgreSQL isolado. Sem interferência entre testes, sem estado residual, sem flakiness.
Testando cache com Redis
O padrão é o mesmo para qualquer serviço. Veja um exemplo com Redis:
package cache_test
import (
"context"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/testcontainers/testcontainers-go"
tcRedis "github.com/testcontainers/testcontainers-go/modules/redis"
)
func setupRedis(t *testing.T) *redis.Client {
t.Helper()
ctx := context.Background()
redisContainer, err := tcRedis.Run(ctx, "redis:7-alpine")
if err != nil {
t.Fatalf("falha ao iniciar container redis: %v", err)
}
t.Cleanup(func() {
if err := redisContainer.Terminate(ctx); err != nil {
t.Logf("falha ao terminar container: %v", err)
}
})
endpoint, err := redisContainer.Endpoint(ctx, "")
if err != nil {
t.Fatalf("falha ao obter endpoint: %v", err)
}
client := redis.NewClient(&redis.Options{
Addr: endpoint,
})
return client
}
func TestCache_SetAndGet(t *testing.T) {
client := setupRedis(t)
ctx := context.Background()
// Define um valor com TTL de 10 segundos
err := client.Set(ctx, "usuario:1:nome", "Carlos", 10*time.Second).Err()
if err != nil {
t.Fatalf("erro ao definir cache: %v", err)
}
// Recupera o valor
val, err := client.Get(ctx, "usuario:1:nome").Result()
if err != nil {
t.Fatalf("erro ao ler cache: %v", err)
}
if val != "Carlos" {
t.Errorf("valor esperado 'Carlos', recebeu '%s'", val)
}
}
func TestCache_TTLExpiration(t *testing.T) {
client := setupRedis(t)
ctx := context.Background()
// Define valor com TTL muito curto
err := client.Set(ctx, "temp:token", "abc123", 1*time.Second).Err()
if err != nil {
t.Fatalf("erro ao definir cache: %v", err)
}
// Espera expirar
time.Sleep(2 * time.Second)
// Tenta ler -- deve retornar redis.Nil
_, err = client.Get(ctx, "temp:token").Result()
if err != redis.Nil {
t.Errorf("esperava redis.Nil após TTL, recebeu: %v", err)
}
}
Esse teste de TTL é impossível de reproduzir fielmente com mocks. Com Testcontainers, você testa o comportamento real do Redis.
Otimizando o tempo de execução
Subir um container por teste pode ser lento. Aqui estão estratégias para manter os testes rápidos:
Reusar containers com TestMain
package repo_test
import (
"database/sql"
"os"
"testing"
)
var testDB *sql.DB
func TestMain(m *testing.M) {
// Sobe o container uma vez para todo o pacote
ctx := context.Background()
pgContainer, _ := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
)
connStr, _ := pgContainer.ConnectionString(ctx, "sslmode=disable")
testDB, _ = sql.Open("postgres", connStr)
// Roda todos os testes do pacote
code := m.Run()
// Limpa tudo
pgContainer.Terminate(ctx)
os.Exit(code)
}
Usar imagens Alpine
Sempre prefira imagens alpine ou slim – elas são menores e sobem mais rápido:
// Prefira isso:
postgres.Run(ctx, "postgres:16-alpine")
// Em vez disso:
postgres.Run(ctx, "postgres:16")
Paralelizar testes
Use t.Parallel() com containers individuais para rodar testes simultaneamente:
func TestConcurrent_CreateUsers(t *testing.T) {
t.Parallel()
db := setupPostgres(t)
repo := NewRepository(db)
// ...
}
Integrando no CI
No seu pipeline de CI, basta garantir que Docker esteja disponível. Para Gitea Actions, o runner precisa de acesso ao Docker socket. Um exemplo de step:
- name: Rodar testes de integração
run: go test -v -count=1 -tags=integration ./...
env:
TESTCONTAINERS_RYUK_DISABLED: "true"
A variável TESTCONTAINERS_RYUK_DISABLED desativa o container auxiliar Ryuk em ambientes onde o Docker socket tem permissões restritas. Isso é comum em runners self-hosted.
Se você usa TDD e CI/CD em Go, Testcontainers se encaixa naturalmente no pipeline existente.
Módulos disponíveis
O Testcontainers para Go oferece módulos prontos para dezenas de serviços:
| Serviço | Módulo | Uso comum |
|---|---|---|
| PostgreSQL | modules/postgres | Repositórios, migrations |
| MySQL | modules/mysql | Repositórios legados |
| Redis | modules/redis | Cache, sessões |
| MongoDB | modules/mongodb | Documentos, logs |
| Kafka | modules/kafka | Streaming, eventos |
| Elasticsearch | modules/elasticsearch | Busca, analytics |
| LocalStack | modules/localstack | AWS S3, SQS, SNS |
| RabbitMQ | modules/rabbitmq | Filas, pub/sub |
Se seu serviço não tem módulo, você pode usar testcontainers.GenericContainer com qualquer imagem Docker.
Quando usar Testcontainers vs mocks
A regra é simples:
- Mocks: lógica de negócio, serviços externos que você não controla, testes unitários rápidos
- Testcontainers: repositórios, cache, filas, qualquer integração com infraestrutura que você controla
Os dois se complementam. Use mocks para isolar unidades e Testcontainers para validar integrações. Isso é o que separa uma suite de testes que dá confiança de uma que apenas finge testar.
Se você quer aprofundar seus conhecimentos em testes, veja nosso guia de testes em Go e o tutorial de TDD com CI/CD. Para quem trabalha com bancos de dados, o artigo sobre sqlc para queries type-safe complementa bem o uso de Testcontainers para validar seus repositórios.