Por que Testes de Integração Importam em Go
Testes de integração são diferentes dos testes unitários. Enquanto um teste unitário valida uma função isolada com mocks e dados em memória, um teste de integração verifica o comportamento real do sistema quando múltiplos componentes trabalham juntos. Em Go, isso significa testar sua aplicação contra um banco de dados real, não contra um simulado.
A razão pela qual isso é crítico: bugs de integração nunca aparecem em testes unitários. Você pode ter validações perfeitas em sua camada de acesso a dados, mas descobrir em produção que sua query não funciona com a versão específica do PostgreSQL. Com testcontainers-go, você executa esses testes contra um container Docker real do banco — o mesmo que rodará em produção — sem poluir seu ambiente local ou exigir uma infraestrutura de teste complexa.
Conceitos Fundamentais de testcontainers-go
O que é testcontainers-go?
testcontainers-go é uma biblioteca que permite provisionar e gerenciar containers Docker durante testes de forma programática. Você escreve código Go que sobe um PostgreSQL (ou MySQL, MongoDB, Redis, etc.) em um container, executa seus testes contra ele, e derruba o container automaticamente ao final. Sem shell scripts, sem configuração manual, sem portas hardcoded.
Sob o capô, a biblioteca se comunica com o daemon Docker via API, cria uma rede isolada para os containers, expõe portas aleatórias para evitar conflitos, e fornece uma API limpa para sua linguagem de teste. Você não precisa entender Docker profundamente — a biblioteca abstrai a complexidade.
Por que não usar um banco em arquivo (SQLite)?
Para testes unitários rápidos, SQLite é ótimo. Mas em produção você usa PostgreSQL ou MySQL. O SQLite tem dialeto SQL ligeiramente diferente, comportamentos distintos em transações e concorrência, e indexes que funcionam diferente. Testar contra SQLite e ir para produção com PostgreSQL é como testar sua API com um cliente mock e esperar que funcione com requisições reais. testcontainers-go elimina essa discrepância.
Configurando seu Primeiro Teste com testcontainers-go
Instalação e Dependências
Você precisará do Docker instalado e rodando em sua máquina. A biblioteca Go é instalada como qualquer outra dependência:
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/wait
go get github.com/lib/pq # driver PostgreSQL
Estrutura Básica: Conectando ao PostgreSQL
Aqui está um teste real que provisiona um PostgreSQL, cria uma tabela, insere dados, e valida:
package main
import (
"context"
"database/sql"
"fmt"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
_ "github.com/lib/pq"
)
func TestPostgresIntegration(t *testing.T) {
ctx := context.Background()
// Define o container PostgreSQL
req := testcontainers.ContainerRequest{
Image: "postgres:15-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "testuser",
"POSTGRES_PASSWORD": "testpass",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
}
// Cria e inicia o container
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatalf("Erro ao iniciar container: %v", err)
}
defer container.Terminate(ctx)
// Obtém a porta mapeada (importante: não é sempre 5432)
port, err := container.MappedPort(ctx, "5432")
if err != nil {
t.Fatalf("Erro ao obter porta: %v", err)
}
// Constrói a string de conexão dinamicamente
dsn := fmt.Sprintf("postgres://testuser:testpass@localhost:%s/testdb?sslmode=disable",
port.Port())
// Aguarda a conexão estar pronta
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("Erro ao abrir conexão: %v", err)
}
defer db.Close()
// Aguarda o banco estar realmente pronto
if err := db.PingContext(ctx); err != nil {
t.Fatalf("Erro ao fazer ping no banco: %v", err)
}
// Cria tabela de teste
_, err = db.ExecContext(ctx, `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL
)
`)
if err != nil {
t.Fatalf("Erro ao criar tabela: %v", err)
}
// Insere dados de teste
_, err = db.ExecContext(ctx,
"INSERT INTO users (name, email) VALUES ($1, $2)",
"João Silva", "joao@example.com")
if err != nil {
t.Fatalf("Erro ao inserir dados: %v", err)
}
// Valida os dados
var name, email string
err = db.QueryRowContext(ctx, "SELECT name, email FROM users WHERE id = 1").
Scan(&name, &email)
if err != nil {
t.Fatalf("Erro ao consultar dados: %v", err)
}
if name != "João Silva" || email != "joao@example.com" {
t.Errorf("Dados retornados incorretos: %s, %s", name, email)
}
t.Log("✓ Teste passou com sucesso")
}
Execute com go test -v. Na primeira execução, Docker baixará a imagem do PostgreSQL (alguns minutos). Nas próximas, o teste rodará em segundos.
O que Aconteceu Aqui
A ContainerRequest define qual imagem usar, quais variáveis de ambiente, e qual critério de espera (WaitingFor). O wait.ForLog monitora os logs do container até encontrar a string de sucesso — assim você evita raceconditions onde a porta está aberta mas o banco ainda não aceitava conexões.
GenericContainer cria e inicia o container. Você obtém a porta mapeada (não a original), porque Docker atribui portas aleatórias para evitar conflitos. Sem isso, dois testes rodando em paralelo usariam a mesma porta e falhariam.
Padrões Avançados: Fixtures, Helpers e Múltiplos Containers
Criando um Helper Reutilizável
Repetir toda aquela boilerplate em cada teste é tedioso. Crie um helper:
package tests
import (
"context"
"database/sql"
"fmt"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
_ "github.com/lib/pq"
)
// PostgresContainer encapsula a lógica de setup
type PostgresContainer struct {
Container testcontainers.Container
DB *sql.DB
DSN string
}
// NewPostgresContainer cria um novo container PostgreSQL
func NewPostgresContainer(ctx context.Context, t *testing.T) *PostgresContainer {
req := testcontainers.ContainerRequest{
Image: "postgres:15-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "testuser",
"POSTGRES_PASSWORD": "testpass",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatalf("Erro ao iniciar PostgreSQL: %v", err)
}
port, err := container.MappedPort(ctx, "5432")
if err != nil {
t.Fatalf("Erro ao obter porta: %v", err)
}
dsn := fmt.Sprintf("postgres://testuser:testpass@localhost:%s/testdb?sslmode=disable",
port.Port())
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("Erro ao conectar: %v", err)
}
if err := db.PingContext(ctx); err != nil {
t.Fatalf("Erro ao fazer ping: %v", err)
}
return &PostgresContainer{
Container: container,
DB: db,
DSN: dsn,
}
}
// Cleanup para e remove o container
func (pc *PostgresContainer) Cleanup(ctx context.Context) error {
if pc.DB != nil {
pc.DB.Close()
}
if pc.Container != nil {
return pc.Container.Terminate(ctx)
}
return nil
}
Agora seus testes ficam muito mais limpos:
func TestUserRepository(t *testing.T) {
ctx := context.Background()
pg := NewPostgresContainer(ctx, t)
defer pg.Cleanup(ctx)
// Seu teste aqui
_, err := pg.DB.ExecContext(ctx, "INSERT INTO users (name, email) VALUES ($1, $2)",
"Maria", "maria@test.com")
if err != nil {
t.Fatal(err)
}
}
Testando com Dados Pré-carregados
Muitas vezes você precisa de dados de teste já populados. Use migrations ou SQL files:
func (pc *PostgresContainer) RunMigrations(ctx context.Context, t *testing.T) {
migrations := []string{
`CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL
)`,
`CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id),
total DECIMAL(10, 2) NOT NULL
)`,
}
for _, migration := range migrations {
if _, err := pc.DB.ExecContext(ctx, migration); err != nil {
t.Fatalf("Erro na migração: %v", err)
}
}
}
func (pc *PostgresContainer) SeedTestData(ctx context.Context, t *testing.T) {
// Insere dados iniciais
_, err := pc.DB.ExecContext(ctx, `
INSERT INTO users (name, email) VALUES
('Alice', 'alice@test.com'),
('Bob', 'bob@test.com'),
('Charlie', 'charlie@test.com')
`)
if err != nil {
t.Fatalf("Erro ao inserir dados de teste: %v", err)
}
}
Múltiplos Containers: PostgreSQL + Redis
Às vezes você precisa testar com vários serviços simultaneamente:
func TestUserServiceWithRedisCache(t *testing.T) {
ctx := context.Background()
// PostgreSQL
pgReq := testcontainers.ContainerRequest{
Image: "postgres:15-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "testuser",
"POSTGRES_PASSWORD": "testpass",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
}
pgContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: pgReq,
Started: true,
})
if err != nil {
t.Fatalf("Erro ao iniciar PostgreSQL: %v", err)
}
defer pgContainer.Terminate(ctx)
// Redis
redisReq := testcontainers.ContainerRequest{
Image: "redis:7-alpine",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForLog("Ready to accept connections"),
}
redisContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: redisReq,
Started: true,
})
if err != nil {
t.Fatalf("Erro ao iniciar Redis: %v", err)
}
defer redisContainer.Terminate(ctx)
// Obtém portas
pgPort, _ := pgContainer.MappedPort(ctx, "5432")
redisPort, _ := redisContainer.MappedPort(ctx, "6379")
t.Logf("PostgreSQL rodando na porta %s", pgPort.Port())
t.Logf("Redis rodando na porta %s", redisPort.Port())
// Seu teste aqui usa ambos os containers
}
Executando e Depurando Seus Testes
Rodando Testes em Paralelo
Por padrão, Go testa funções TestXxx sequencialmente. Para testes de integração com containers, paralelo economiza tempo:
go test -v -parallel 4
Cada teste recebe seu próprio container isolado (mesma imagem, diferentes IDs), então não há conflito. testcontainers-go gerencia isso automaticamente.
Visualizando Logs do Container
Se um teste falha, os logs do container são valiosos. A biblioteca oferece um método para capturá-los:
func TestWithLogs(t *testing.T) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:15-alpine",
ExposedPorts: []string{"5432/tcp"},
// ... rest of config
}
container, _ := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
defer container.Terminate(ctx)
// Seu teste...
if t.Failed() {
logs, _ := container.Logs(ctx)
t.Logf("Container logs:\n%s", logs)
}
}
Mantendo o Container Rodando para Debug
Para debugar, às vezes é útil manter o container vivo após o teste falhar:
// Descomente a linha defer para permitir inspeção manual
// defer container.Terminate(ctx)
// Aguarde para inspeccionar manualmente
// time.Sleep(30 * time.Second)
Assim você pode docker ps e docker exec para investigar o estado do banco manualmente.
Conclusão
Você aprendeu três coisas fundamentais sobre testes de integração com Go e testcontainers-go:
-
Testes contra containers reais eliminam surpresas de produção — testar contra PostgreSQL em um container evita bugs que não aparecem em testes unitários com mocks. O banco que você testa é o mesmo que roda em produção.
-
testcontainers-go abstrai a complexidade de Docker — você escreve código Go limpo, não scripts shell ou configurações manuais. A biblioteca provisiona, aguarda readiness, mapeia portas aleatoriedade e faz limpeza automaticamente.
-
Padrões como helpers reutilizáveis escalam bem — encapsular a lógica de setup em métodos torna seus testes legíveis, manuteníveis e reutilizáveis entre múltiplos testes e múltiplos bancos (PostgreSQL, Redis, etc.).
De aqui para frente, procure explorar waitingFor customizados (nem sempre um log é suficiente), reutilizar containers entre testes para ganho de performance quando apropriado, e integrar isso em seu pipeline CI/CD (GitHub Actions, GitLab CI, etc. têm suporte nativo a Docker).