← Voltar para o blog

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

Aprenda Testcontainers em Go para testar PostgreSQL, Redis, filas e APIs com containers reais, migrations, fixtures, CI e boas práticas de produção.

Testcontainers em Go resolve uma dúvida que aparece quando os testes unitários deixam de ser suficientes: como validar código que depende de PostgreSQL, Redis, RabbitMQ, Kafka, S3 local, OpenSearch ou outro serviço externo sem transformar o ambiente de teste em uma gambiarra frágil? Mock ajuda a testar regra de negócio isolada, mas não garante que a query SQL compila, que a migration sobe, que o índice existe, que o TTL do Redis funciona ou que o consumer realmente confirma uma mensagem do jeito esperado.

Em projetos Go, essa fronteira costuma aparecer rápido. A linguagem incentiva interfaces pequenas, go test ./... rápido e código explícito. Só que backends reais dependem de infraestrutura. Uma API com Go e PostgreSQL, um worker com Redis, um serviço com mensageria em Go ou um endpoint que precisa testar idempotência não pode confiar apenas em fake em memória. Em algum momento, você precisa rodar o teste contra um serviço parecido com produção.

Testcontainers cria containers Docker sob demanda durante o teste, expõe a porta real, espera o serviço ficar pronto e devolve a connection string para o código Go. Quando o teste termina, o container é removido. O resultado é um meio-termo forte: mais realista que mock, mais automatizado que ambiente compartilhado e mais reproduzível que depender de um banco instalado na máquina de cada pessoa.

Quando usar Testcontainers em Go

Use Testcontainers quando o comportamento que você quer validar depende de um serviço externo real. Alguns exemplos comuns:

  1. Queries SQL geradas por sqlc ou escritas com database/sql e pgx.
  2. Migrations em Go aplicadas antes do deploy.
  3. Constraints, índices, transações, locks e ON CONFLICT no PostgreSQL.
  4. TTL, scripts Lua, locks distribuídos e tipos específicos do Redis.
  5. Publicação e consumo em brokers como RabbitMQ, Kafka ou NATS.
  6. APIs que precisam testar fluxo completo entre handler HTTP, banco e fila.

Não use Testcontainers para tudo. Funções puras, validação de input, cálculo, parser, formatação e regra de negócio isolada continuam melhores com testes unitários simples e rápidos. O padrão saudável é uma pirâmide: muitos unit tests, alguns testes de integração com containers e poucos testes end-to-end de sistema inteiro.

Se você ainda está organizando a base, leia também testes em Go e testes de tabela em Go. Testcontainers não substitui esses padrões; ele entra quando o teste precisa provar que seu código conversa corretamente com infraestrutura real.

Instalação mínima

O pacote principal é github.com/testcontainers/testcontainers-go. Para serviços populares, prefira os módulos oficiais, porque eles já trazem helpers de imagem, variáveis de ambiente e readiness.

go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
go get github.com/testcontainers/testcontainers-go/modules/redis
go get github.com/jackc/pgx/v5/pgxpool

O requisito operacional é ter Docker disponível no ambiente onde o teste roda. Em máquina local, normalmente basta Docker Desktop, Colima, Rancher Desktop ou Docker Engine. Em CI, você precisa escolher um runner que permita containers, configurar Docker-in-Docker com cuidado ou usar um serviço de Docker remoto.

Antes de empilhar containers em todos os pacotes, valide um teste simples no seu CI. A maior parte das dores de Testcontainers não está na API Go; está em rede, permissões, cache de imagens e limites de CPU/memória no runner.

Exemplo com PostgreSQL

Um teste de integração útil deve criar o banco, aplicar schema, rodar o código real e verificar o resultado por uma query independente. O exemplo abaixo usa o módulo de PostgreSQL e pgxpool.

package userrepo_test

import (
    "context"
    "testing"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestUserRepositoryCreate(t *testing.T) {
    ctx := context.Background()

    container, err := postgres.Run(ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("app"),
        postgres.WithUsername("app"),
        postgres.WithPassword("secret"),
        postgres.WithInitScripts("testdata/schema.sql"),
        wait.ForLog("database system is ready to accept connections").
            WithOccurrence(2).
            WithStartupTimeout(30*time.Second),
    )
    if err != nil {
        t.Fatalf("start postgres: %v", err)
    }
    t.Cleanup(func() { _ = container.Terminate(ctx) })

    dsn, err := container.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        t.Fatalf("connection string: %v", err)
    }

    db, err := pgxpool.New(ctx, dsn)
    if err != nil {
        t.Fatalf("connect postgres: %v", err)
    }
    t.Cleanup(db.Close)

    repo := NewUserRepository(db)
    user, err := repo.Create(ctx, "[email protected]", "Ana")
    if err != nil {
        t.Fatalf("create user: %v", err)
    }

    var count int
    err = db.QueryRow(ctx, "SELECT count(*) FROM users WHERE id = $1", user.ID).Scan(&count)
    if err != nil {
        t.Fatalf("count users: %v", err)
    }
    if count != 1 {
        t.Fatalf("users count = %d, want 1", count)
    }
}

O arquivo testdata/schema.sql pode ser um schema pequeno ou o resultado das suas migrations. Em projetos com sqlc em Go, esse teste é especialmente valioso: ele valida a query gerada, o driver, o schema real e a transação de ponta a ponta.

Migrations no teste: use o mesmo caminho do deploy

Uma armadilha comum é criar um schema simplificado só para teste. Parece mais rápido, mas abre uma diferença perigosa entre teste e produção. Se o deploy aplica migrations, o teste de integração também deve aplicar migrations. Assim você testa o contrato real: tabelas, índices, constraints, enums, funções e permissões.

Um desenho prático:

  1. Suba o container PostgreSQL limpo.
  2. Rode o mesmo migrator usado no deploy, apontando para a connection string do container.
  3. Insira fixtures mínimas para o cenário.
  4. Execute o código Go real.
  5. Verifique estado final com query clara.

Você pode usar ferramentas como golang-migrate, tern, atlas, goose ou um migrator interno. O importante é que o teste falhe se uma migration quebrar. Isso evita o clássico “passou no mock, falhou no banco”.

Fixtures sem virar bagunça

Testes de integração morrem quando as fixtures ficam grandes demais. Prefira dados pequenos, nomeados e próximos do teste. Use builders simples em Go ou arquivos SQL por cenário. Evite importar dump de produção, porque isso deixa o teste lento, frágil e difícil de revisar.

Uma boa fixture responde a uma pergunta específica: “existe um usuário ativo com plano premium”, “há uma cobrança pendente”, “o evento já foi processado”. Se você precisa de dezenas de registros para testar uma regra, talvez a regra esteja acoplada demais ao banco ou o teste esteja tentando validar muitos comportamentos ao mesmo tempo.

Também limpe o estado entre testes. Três opções comuns:

  1. Criar um container por teste: mais isolado, porém mais lento.
  2. Criar um container por pacote e truncar tabelas: mais rápido, exige disciplina.
  3. Usar transação por teste e rollback: eficiente para banco, mas não cobre efeitos externos fora da transação.

Para começar, container por teste é mais simples. Quando a suíte crescer, meça antes de otimizar.

Testando Redis com comportamento real

Redis é outro caso em que mock engana. TTL, SET NX, scripts Lua, streams e concorrência têm detalhes que aparecem apenas no servidor real. Um teste com container pode validar locks, cache e rate limiting sem depender de um Redis compartilhado. O exemplo abaixo usa o módulo github.com/testcontainers/testcontainers-go/modules/redis.

container, err := redis.Run(ctx, "redis:7-alpine")
if err != nil {
    t.Fatalf("start redis: %v", err)
}
t.Cleanup(func() { _ = container.Terminate(ctx) })

addr, err := container.ConnectionString(ctx)
if err != nil {
    t.Fatalf("redis addr: %v", err)
}

Depois conecte com o client usado pela aplicação, como go-redis, e teste o comportamento que importa. Se você tem rate limiting em Go com Redis, por exemplo, valide estouro de limite, expiração da janela e concorrência. Esse é o tipo de bug que raramente aparece em mock.

CI/CD: deixe previsível antes de deixar rápido

No CI, o objetivo inicial é previsibilidade. Puxe imagens oficiais, use tags fixas como postgres:16-alpine, limite paralelismo e registre logs quando o container falhar. Em pipelines com pouco recurso, rodar todos os testes de integração em paralelo pode derrubar o runner e gerar falhas intermitentes.

Boas práticas:

  1. Separe testes de integração com build tag, como //go:build integration, se eles forem pesados.
  2. Rode go test ./... sempre e go test -tags=integration ./... em pull requests principais ou antes do deploy.
  3. Use timeout explícito no comando de teste.
  4. Cacheie camadas Docker quando o CI permitir.
  5. Evite imagens latest; fixe versão.

Um comando simples para pipeline:

go test -count=1 -timeout=10m -tags=integration ./...

Se o projeto é pequeno, pode rodar tudo junto. Se o repositório já tem muitos pacotes, separar unitário de integração ajuda a manter feedback rápido para mudanças simples.

Erros comuns

O primeiro erro é testar detalhe demais. Teste o contrato entre sua aplicação e o serviço, não a implementação interna do container. Você não precisa provar que PostgreSQL funciona; precisa provar que suas migrations, queries e transações funcionam no PostgreSQL.

O segundo é ignorar readiness. Um container criado não significa serviço pronto. Use waits específicos: log, porta, healthcheck ou query. Falhas intermitentes quase sempre vêm de teste que tenta conectar antes do banco inicializar.

O terceiro é depender de ordem entre testes. Cada teste deve preparar seu estado. Se TestB só passa depois de TestA, a suíte vai quebrar com paralelismo, cache ou execução seletiva.

O quarto é esconder erro de infraestrutura como skip automático. Se Docker não está disponível no CI onde testes de integração deveriam rodar, o pipeline precisa falhar. Skip pode ser aceitável localmente, mas em ambiente de validação ele mascara regressão.

O quinto é esquecer carreira. Muitas vagas Go backend e vagas Go DevOps/SRE citam testes, PostgreSQL, Docker, CI/CD, Redis, Kubernetes e observabilidade. Saber explicar Testcontainers em entrevista mostra maturidade prática: você não apenas escreve handler, você prova que ele conversa com dependências reais. Para comparar como esse tema aparece em outras stacks brasileiras, acompanhe também o portal Python Dev Brasil reúne vagas Python e dados no Brasil.

Checklist para adotar sem dor

Comece por um fluxo crítico e pequeno. Um teste de criação de usuário com PostgreSQL, uma migration importante, um lock Redis ou um consumer simples já traz valor. Depois expanda para cenários onde bug em infraestrutura custa caro: idempotência, transação, retry, concorrência e rollback.

Checklist prático:

  1. Use imagem com versão fixa.
  2. Aplique migrations reais.
  3. Mantenha fixtures mínimas.
  4. Espere readiness explicitamente.
  5. Feche conexões e termine containers com t.Cleanup.
  6. Separe testes pesados com tag se necessário.
  7. Rode no CI antes do deploy.
  8. Meça duração antes de otimizar.

Testcontainers não torna todos os testes perfeitos, mas fecha uma lacuna importante entre unit tests e produção. Em Go, onde simplicidade e feedback rápido importam, ele funciona melhor quando usado com parcimônia: poucos testes de integração bem escolhidos, cobrindo contratos que mocks não conseguem garantir.

O próximo passo natural é combinar este guia com Docker para Go, TDD e CI/CD em Go, migrations em Go e observabilidade em Go. Assim seus testes deixam de ser apenas uma etapa local e passam a fazer parte do caminho seguro entre código, pipeline e produção.