Testes de integração em Go
LidoNesta aula, você aprenderá a criar testes de integração em Go, desde a configuração de containers de teste até a integração com bancos de dados reais e a execução em pipelines de CI. Vamos explorar boas práticas, exemplos práticos e exercícios para consolidar o conhecimento.
Testes de integração são essenciais para verificar se os diferentes módulos de um sistema funcionam corretamente quando combinados. Em Go, isso envolve testar a interação do nosso código com componentes externos, como bancos de dados, filas ou serviços HTTP. Nesta aula, você vai aprender a criar testes de integração robustos, utilizando containers de teste, bancos reais e configurando tudo para rodar em pipelines de integração contínua.
Vamos começar entendendo o papel dos containers de teste, depois discutir a escolha entre usar um banco real ou mocks, e em seguida montar um ambiente de teste prático com Docker. Finalmente, veremos como integrar esses testes no seu fluxo de CI.
Containers de teste (visão geral)
Containers de teste são ambientes isolados e descartáveis que replicam serviços externos (como bancos de dados, Redis, etc.) de forma consistente. Eles são criados sob demanda durante a execução dos testes e destruídos ao final, garantindo que cada execução comece com um estado limpo e previsível.
Em Go, a biblioteca mais popular para gerenciar containers de teste é o testcontainers-go. Ela permite que você defina um container (por exemplo, PostgreSQL) e o inicie programaticamente nos seus testes. Veja um exemplo básico:
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func setupPostgres(t *testing.T) (testcontainers.Container, string) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
"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.Fatal(err)
}
mappedPort, err := container.MappedPort(ctx, "5432")
if err != nil {
t.Fatal(err)
}
connString := fmt.Sprintf("host=localhost port=%s user=test password=test dbname=testdb sslmode=disable", mappedPort.Port())
return container, connString
}
No exemplo acima, iniciamos um container PostgreSQL e obtemos a string de conexão para o banco exposto na porta mapeada. O container é iniciado antes do teste e deve ser encerrado ao final (geralmente usando defer container.Terminate(ctx)).
Banco real vs mock
Uma decisão importante em testes de integração é usar um banco de dados real (via container) ou usar mocks para simular o banco. Vamos comparar as abordagens:
- Banco real: Testa consultas SQL, constraints e o comportamento real do SGBD. É mais fiel ao ambiente de produção, mas é mais lento e requer infraestrutura (mesmo que efêmera).
- Mock: Usa uma implementação falsa da interface do repositório. É rápido e não depende de serviços externos, mas não verifica a sintaxe SQL nem a integração com o banco.
Para testes de integração, o ideal é usar um banco real, pois o objetivo é justamente garantir que o código funcione com o banco de verdade. No entanto, para testes unitários, mocks são aceitáveis. Muitas vezes, combinamos ambos: testes unitários com mocks para lógica de negócio e testes de integração com banco real para a camada de persistência.
Quando você opta por banco real, lembre-se de que cada teste deve ser isolado. Uma boa prática é executar migrações antes de cada teste ou usar transações que podem ser revertidas. No caso de transações, você pode usar t.Cleanup para fazer rollback.
func TestInsertUser(t *testing.T) {
db := setupDB(t) // abre conexão e executa migrações
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
repo := NewUserRepository(tx)
err = repo.Insert(context.Background(), User{Name: "Alice"})
if err != nil {
tx.Rollback()
t.Fatal(err)
}
// Verifica se o usuário foi inserido (dentro da transação)
var count int
err = tx.QueryRow("SELECT COUNT(*) FROM users WHERE name = $1", "Alice").Scan(&count)
if err != nil {
tx.Rollback()
t.Fatal(err)
}
if count != 1 {
tx.Rollback()
t.Errorf("expected 1 user, got %d", count)
}
// Rollback para não afetar outros testes
defer tx.Rollback()
}
Setup
O setup dos testes de integração envolve preparar o ambiente, como inicializar containers, configurar variáveis de ambiente e executar migrações. Em Go, podemos usar funções auxiliares que são chamadas no início de cada teste ou em TestMain para configurar recursos compartilhados.
Uma abordagem comum é usar um TestMain para subir os containers uma única vez e depois reutilizá-los entre os testes, acelerando a execução. No entanto, isso pode causar dependência entre testes. Uma alternativa é subir containers por grupo de testes usando t.Cleanup.
Aqui está um exemplo de setup usando TestMain:
var testDB *sql.DB
func TestMain(m *testing.M) {
ctx := context.Background()
container, connString := setupPostgres() // implementação omitida
defer container.Terminate(ctx)
db, err := sql.Open("postgres", connString)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Executa migrações
if err := runMigrations(db); err != nil {
log.Fatal(err)
}
testDB = db
code := m.Run()
os.Exit(code)
}
No exemplo, o container é iniciado uma vez, as migrações são executadas e os testes usam o testDB global. Isso reduz o overhead de criar containers para cada teste, mas você deve garantir que os testes não interfiram entre si (por exemplo, usando transações ou limpando as tabelas entre os testes).
Outra prática é usar um pacote separado para os testes de integração, por exemplo, integration_test, e usar a tag de build //go:build integration para que eles sejam executados apenas quando desejado.
//go:build integration
package integration
import "testing"
func TestUserIntegration(t *testing.T) {
// usa testDB
}
Assim, você pode rodar apenas testes de integração com go test -tags=integration ./....
CI
Integrar testes de integração no seu pipeline de CI é essencial para garantir que as mudanças não quebrem funcionalidades críticas. Ao usar containers de teste, você não precisa de serviços externos no CI, pois eles são provisionados dinamicamente. Isso torna o pipeline mais portátil e confiável.
Uma configuração típica de CI para Go com testes de integração inclui:
- Instalar Go e as dependências do projeto.
- Executar testes unitários (sem tag de integração).
- Executar testes de integração (com tag de integração) ou todos os testes se você não usar tags.
Aqui está um exemplo de workflow no GitHub Actions:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install dependencies
run: go mod download
- name: Run unit tests
run: go test ./... -tags=unit
- name: Run integration tests
run: go test ./... -tags=integration
No exemplo acima, os testes de integração são executados em um job separado ou no mesmo job. Como usamos containers, não precisamos configurar serviços no GitHub Actions. A biblioteca testcontainers-go cuida de baixar a imagem e iniciar o container.
É importante também considerar o tempo de execução: testes de integração podem ser mais demorados. Para otimizar, você pode rodá-los em paralelo com outros jobs ou usar cache de imagens Docker no CI.
Boas práticas
- Isolamento: Cada teste deve ser independente e não depender de dados criados por outros testes.
- Migrações: Sempre execute migrações antes de iniciar os testes para garantir que o schema está atualizado.
- Cleanup: Use
t.Cleanupoudeferpara encerrar containers e fechar conexões. - Seleção de testes: Use tags de build para separar testes unitários de integração, permitindo execução rápida em desenvolvimento.
- Paralelismo: Se possível, use
t.Parallel()para testes que não compartilham recursos, mas cuidado com conflitos em banco.
Referências
- Documentação oficial do pacote testing
- Testcontainers para Go
- Documentação do Docker
- GitHub Actions documentation
- Usando módulos Go
- Documentação do PostgreSQL
Exercícios
Escreva um teste de integração que verifica a inserção e recuperação de um usuário em um banco PostgreSQL usando o testcontainers-go. Inclua a configuração do container e a limpeza após o teste.
✓ Resposta:func TestUserInsertAndRetrieve(t *testing.T) { ctx := context.Background() container, connString := setupPostgres(t) defer container.Terminate(ctx) db, err := sql.Open("postgres", connString) if err != nil { t.Fatal(err) } defer db.Close() // Executa migração simples _, err = db.Exec(`CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`) if err != nil { t.Fatal(err) } repo := NewUserRepository(db) user := User{Name: "Alice"} id, err := repo.Insert(ctx, user) if err != nil { t.Fatal(err) } got, err := repo.Get(ctx, id) if err != nil { t.Fatal(err) } if got.Name != user.Name { t.Errorf("expected %s, got %s", user.Name, got.Name) } }Modifique o setup do exercício anterior para usar uma transação que faz rollback ao final, de modo que os dados não persistam entre execuções.
✓ Resposta:func TestUserInsertWithTx(t *testing.T) { ctx := context.Background() container, connString := setupPostgres(t) defer container.Terminate(ctx) db, err := sql.Open("postgres", connString) if err != nil { t.Fatal(err) } defer db.Close() tx, err := db.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() // Executa criação de tabela dentro da transação _, err = tx.Exec(`CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`) if err != nil { t.Fatal(err) } repo := NewUserRepository(tx) user := User{Name: "Bob"} id, err := repo.Insert(ctx, user) if err != nil { t.Fatal(err) } var name string err = tx.QueryRow("SELECT name FROM users WHERE id = $1", id).Scan(&name) if err != nil { t.Fatal(err) } if name != user.Name { t.Errorf("expected %s, got %s", user.Name, name) } // Rollback acontece no defer, então nada é persistido }Crie uma suíte de testes de integração que usa
TestMainpara iniciar um container único, executar migrações e fechar o container após todos os testes. Escreva dois testes que usam esse banco compartilhado.✓ Resposta:package integration import ( "context" "database/sql" "log" "os" "testing" _ "github.com/lib/pq" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) var testDB *sql.DB func TestMain(m *testing.M) { ctx := context.Background() req := testcontainers.ContainerRequest{ Image: "postgres:16-alpine", ExposedPorts: []string{"5432/tcp"}, Env: map[string]string{ "POSTGRES_USER": "test", "POSTGRES_PASSWORD": "test", "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 { log.Fatal(err) } defer container.Terminate(ctx) mappedPort, err := container.MappedPort(ctx, "5432") if err != nil { log.Fatal(err) } connStr := fmt.Sprintf("host=localhost port=%s user=test password=test dbname=testdb sslmode=disable", mappedPort.Port()) testDB, err = sql.Open("postgres", connStr) if err != nil { log.Fatal(err) } defer testDB.Close() // Executa migrações if err := migrate(testDB); err != nil { log.Fatal(err) } code := m.Run() os.Exit(code) } func migrate(db *sql.DB) error { _, err := db.Exec(`CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`) return err } func TestInsertUser(t *testing.T) { repo := NewUserRepository(testDB) id, err := repo.Insert(context.Background(), User{Name: "Alice"}) if err != nil { t.Fatal(err) } if id == 0 { t.Error("expected non-zero id") } } func TestGetUser(t *testing.T) { repo := NewUserRepository(testDB) // Insere um usuário primeiro id, err := repo.Insert(context.Background(), User{Name: "Bob"}) if err != nil { t.Fatal(err) } got, err := repo.Get(context.Background(), id) if err != nil { t.Fatal(err) } if got.Name != "Bob" { t.Errorf("expected Bob, got %s", got.Name) } }Configure um arquivo YAML para GitHub Actions que executa testes unitários e de integração com tags. Inclua etapas para instalar Go, baixar dependências e rodar os testes.
✓ Resposta:name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.22' - name: Install dependencies run: go mod download - name: Run unit tests run: go test -tags=unit ./... - name: Run integration tests run: go test -tags=integration ./...Explique a diferença entre usar um banco real e mocks em testes de integração. Dê um exemplo de cenário em que cada abordagem é mais adequada.
✓ Resposta: Em testes de integração, o objetivo é verificar a interação entre componentes reais. Portanto, usar um banco real (via container) é mais adequado quando você precisa validar consultas SQL, constraints, transações e o comportamento específico do SGBD. Por exemplo, testar uma query complexa que usa joins e índices. Mocks são mais adequados para testes unitários, onde você quer isolar a lógica de negócio e não se preocupar com o banco. Por exemplo, testar um serviço que depende de um repositório, mas você quer simular o repositório para focar na lógica do serviço. Mocks também são úteis quando o banco real é difícil de configurar ou quando a velocidade é crítica. Em resumo: banco real para integração, mock para unidade.