← Voltar para o blog

Testcontainers em Go: Testes de Integração com Containers Reais

Aprenda a usar Testcontainers em Go para testes de integração com PostgreSQL, Redis e outros serviços reais em containers. Guia prático com exemplos.

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çoMóduloUso comum
PostgreSQLmodules/postgresRepositórios, migrations
MySQLmodules/mysqlRepositórios legados
Redismodules/redisCache, sessões
MongoDBmodules/mongodbDocumentos, logs
Kafkamodules/kafkaStreaming, eventos
Elasticsearchmodules/elasticsearchBusca, analytics
LocalStackmodules/localstackAWS S3, SQS, SNS
RabbitMQmodules/rabbitmqFilas, 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.