<h2>Por que Testes de Integração Importam em Go</h2>
<p>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.</p>
<p>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.</p>
<h2>Conceitos Fundamentais de testcontainers-go</h2>
<h3>O que é testcontainers-go?</h3>
<p>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.</p>
<p>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.</p>
<h3>Por que não usar um banco em arquivo (SQLite)?</h3>
<p>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.</p>
<h2>Configurando seu Primeiro Teste com testcontainers-go</h2>
<h3>Instalação e Dependências</h3>
<p>Você precisará do Docker instalado e rodando em sua máquina. A biblioteca Go é instalada como qualquer outra dependência:</p>
<pre><code class="language-bash">go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/wait
go get github.com/lib/pq # driver PostgreSQL</code></pre>
<h3>Estrutura Básica: Conectando ao PostgreSQL</h3>
<p>Aqui está um teste real que provisiona um PostgreSQL, cria uma tabela, insere dados, e valida:</p>
<pre><code class="language-go">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")
}</code></pre>
<p>Execute com <code>go test -v</code>. Na primeira execução, Docker baixará a imagem do PostgreSQL (alguns minutos). Nas próximas, o teste rodará em segundos.</p>
<h3>O que Aconteceu Aqui</h3>
<p>A <code>ContainerRequest</code> define qual imagem usar, quais variáveis de ambiente, e qual critério de espera (<code>WaitingFor</code>). O <code>wait.ForLog</code> 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.</p>
<p><code>GenericContainer</code> cria e inicia o container. Você obtém a porta <strong>mapeada</strong> (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.</p>
<h2>Padrões Avançados: Fixtures, Helpers e Múltiplos Containers</h2>
<h3>Criando um Helper Reutilizável</h3>
<p>Repetir toda aquela boilerplate em cada teste é tedioso. Crie um helper:</p>
<pre><code class="language-go">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
}</code></pre>
<p>Agora seus testes ficam muito mais limpos:</p>
<pre><code class="language-go">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)
}
}</code></pre>
<h3>Testando com Dados Pré-carregados</h3>
<p>Muitas vezes você precisa de dados de teste já populados. Use migrations ou SQL files:</p>
<pre><code class="language-go">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)
}
}</code></pre>
<h3>Múltiplos Containers: PostgreSQL + Redis</h3>
<p>Às vezes você precisa testar com vários serviços simultaneamente:</p>
<pre><code class="language-go">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
}</code></pre>
<h2>Executando e Depurando Seus Testes</h2>
<h3>Rodando Testes em Paralelo</h3>
<p>Por padrão, Go testa funções <code>TestXxx</code> sequencialmente. Para testes de integração com containers, paralelo economiza tempo:</p>
<pre><code class="language-bash">go test -v -parallel 4</code></pre>
<p>Cada teste recebe seu próprio container isolado (mesma imagem, diferentes IDs), então não há conflito. testcontainers-go gerencia isso automaticamente.</p>
<h3>Visualizando Logs do Container</h3>
<p>Se um teste falha, os logs do container são valiosos. A biblioteca oferece um método para capturá-los:</p>
<pre><code class="language-go">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)
}
}</code></pre>
<h3>Mantendo o Container Rodando para Debug</h3>
<p>Para debugar, às vezes é útil manter o container vivo após o teste falhar:</p>
<pre><code class="language-go">// Descomente a linha defer para permitir inspeção manual
// defer container.Terminate(ctx)
// Aguarde para inspeccionar manualmente
// time.Sleep(30 * time.Second)</code></pre>
<p>Assim você pode <code>docker ps</code> e <code>docker exec</code> para investigar o estado do banco manualmente.</p>
<h2>Conclusão</h2>
<p>Você aprendeu três coisas fundamentais sobre testes de integração com Go e testcontainers-go:</p>
<ol>
<li><strong>Testes contra containers reais eliminam surpresas de produção</strong> — 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.</li>
</ol>
<ol>
<li><strong>testcontainers-go abstrai a complexidade de Docker</strong> — 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.</li>
</ol>
<ol>
<li><strong>Padrões como helpers reutilizáveis escalam bem</strong> — 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.).</li>
</ol>
<p>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).</p>
<h2>Referências</h2>
<ul>
<li><a href="https://golang.testcontainers.org/" target="_blank" rel="noopener noreferrer">testcontainers-go Documentação Oficial</a></li>
<li><a href="https://pkg.go.dev/database/sql" target="_blank" rel="noopener noreferrer">Go database/sql Package</a></li>
<li><a href="https://hub.docker.com/_/postgres" target="_blank" rel="noopener noreferrer">PostgreSQL Docker Image</a></li>
<li><a href="https://www.youtube.com/watch?v=ndmB0bHoyjc" target="_blank" rel="noopener noreferrer">Testing in Go: The Basics</a></li>
<li><a href="https://golang.org/doc/effective_go#generics" target="_blank" rel="noopener noreferrer">Table-Driven Tests in Go</a></li>
</ul>
<p><!-- FIM --></p>