Go Admin

Guia Completo de Testes de Integração em Go: Banco Real com testcontainers-go Já leu

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

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:

  1. 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.

  2. 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.

  3. 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).

Referências


Artigos relacionados