Testing de integração com testcontainers-go

1. Introdução aos testes de integração em Go

Em aplicações Go reais, testes unitários isolam funções e métodos, mas não garantem que o sistema funcione corretamente quando integrado a bancos de dados, filas de mensagens ou caches. Testes de integração preenchem essa lacuna, validando a comunicação entre componentes reais.

Enquanto testes unitários focam em lógica isolada e testes end-to-end cobrem fluxos completos (muitas vezes em ambientes de staging), os testes de integração se concentram em camadas específicas, como repositórios ou serviços que dependem de recursos externos. O testcontainers-go surge como solução elegante: ele gerencia containers Docker programaticamente, permitindo que você suba dependências reais (PostgreSQL, Redis, RabbitMQ) durante os testes e as destrua ao final, sem poluir seu ambiente de desenvolvimento.

2. Configurando o ambiente de teste

Para começar, instale a biblioteca:

go get github.com/testcontainers/testcontainers-go

Requisitos: Docker em execução e contexto configurado. O testcontainers-go se comunica com o daemon Docker via socket padrão.

Estrutura de diretórios recomendada:

projeto/
├── internal/
│   └── repository/
│       ├── user_repository.go
│       └── user_repository_test.go
└── integration_test.go   (opcional, com build tags)

Use build tags para separar testes de integração:

//go:build integration
// +build integration

package repository_test

Execute com: go test -tags=integration ./...

3. Primeiro container: PostgreSQL para testes

Vamos criar um container PostgreSQL dinâmico:

package repository_test

import (
    "context"
    "database/sql"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestPostgresContainer(t *testing.T) {
    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"),
    }

    postgres, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil {
        t.Fatal(err)
    }
    defer postgres.Terminate(ctx)

    endpoint, err := postgres.Endpoint(ctx, "5432")
    if err != nil {
        t.Fatal(err)
    }

    dsn := "postgres://test:test@" + endpoint + "/testdb?sslmode=disable"
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    // Executa migrations
    _, err = db.Exec(`CREATE TABLE IF NOT EXISTS users (
        id SERIAL PRIMARY KEY,
        name TEXT NOT NULL
    )`)
    if err != nil {
        t.Fatal(err)
    }

    t.Log("Container PostgreSQL rodando em:", endpoint)
}

A função wait.ForLog aguarda até que o PostgreSQL emita o log de prontidão, garantindo que a conexão seja estabelecida com segurança.

4. Gerenciando ciclo de vida do container

Para setups mais organizados, use TestMain:

package repository_test

import (
    "context"
    "database/sql"
    "os"
    "testing"

    "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"),
    }

    postgres, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil {
        os.Exit(1)
    }
    defer postgres.Terminate(ctx)

    endpoint, _ := postgres.Endpoint(ctx, "5432")
    dsn := "postgres://test:test@" + endpoint + "/testdb?sslmode=disable"
    testDB, _ = sql.Open("postgres", dsn)

    code := m.Run()
    os.Exit(code)
}

Para timeouts, use context.WithTimeout e verifique logs com postgres.FollowOutput() em cenários de debug.

5. Testando repositórios com banco de dados real

Implemente um repositório simples:

// internal/repository/user_repository.go
package repository

import "database/sql"

type User struct {
    ID   int
    Name string
}

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(name string) (int, error) {
    var id int
    err := r.db.QueryRow("INSERT INTO users (name) VALUES ($1) RETURNING id", name).Scan(&id)
    return id, err
}

func (r *UserRepository) FindByID(id int) (*User, error) {
    var u User
    err := r.db.QueryRow("SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name)
    if err != nil {
        return nil, err
    }
    return &u, nil
}

Teste de integração:

// internal/repository/user_repository_test.go
package repository_test

import (
    "testing"

    "github.com/stretchr/testify/require"
    "meuprojeto/internal/repository"
)

func TestUserRepository_CreateAndFind(t *testing.T) {
    repo := repository.NewUserRepository(testDB)

    id, err := repo.Create("Alice")
    require.NoError(t, err)
    require.Greater(t, id, 0)

    user, err := repo.FindByID(id)
    require.NoError(t, err)
    require.Equal(t, "Alice", user.Name)
}

func TestUserRepository_FindByID_NotFound(t *testing.T) {
    repo := repository.NewUserRepository(testDB)

    _, err := repo.FindByID(999)
    require.Error(t, err)
}

6. Containers para Redis, RabbitMQ e outras dependências

Redis:

redisReq := testcontainers.ContainerRequest{
    Image:        "redis:7-alpine",
    ExposedPorts: []string{"6379/tcp"},
    WaitingFor:   wait.ForLog("* Ready to accept connections"),
}
redisC, _ := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
    ContainerRequest: redisReq,
    Started:          true,
})
defer redisC.Terminate(ctx)

endpoint, _ := redisC.Endpoint(ctx, "6379")
// Use go-redis com addr := endpoint

RabbitMQ:

rabbitReq := testcontainers.ContainerRequest{
    Image:        "rabbitmq:3-management-alpine",
    ExposedPorts: []string{"5672/tcp", "15672/tcp"},
    WaitingFor:   wait.ForLog("started TCP listener on"),
}

Para múltiplos containers, inicie-os no TestMain e compartilhe as conexões.

7. Boas práticas e padrões avançados

Container personalizado com GenericContainer:

customReq := testcontainers.GenericContainerRequest{
    ContainerRequest: testcontainers.ContainerRequest{
        Image: "minha-imagem:1.0",
        Cmd:   []string{"--config", "/etc/app/config.yml"},
    },
    Started: true,
}

Isolamento entre testes: prefira recriar o container para cada pacote de teste (via TestMain), mas para testes individuais que exigem isolamento, use sync.Once para compartilhar um container entre múltiplos testes do mesmo pacote:

var (
    once     sync.Once
    globalDB *sql.DB
)

func getTestDB(t *testing.T) *sql.DB {
    once.Do(func() {
        // setup do container e banco
    })
    return globalDB
}

Reset de dados: para evitar recriação, execute TRUNCATE ou DELETE entre testes, mas cuidado com concorrência.

8. Integração com CI/CD e conclusão

No GitHub Actions, garanta que o Docker esteja disponível:

- name: Testes de integração
  run: go test -tags=integration -v ./...

A diferença principal entre go test -tags=integration e execução local é que em CI você precisa de um runner com Docker (ubuntu-latest já inclui). Use testcontainers-go com Reaper (container auxiliar para limpeza) que funciona nativamente.

Resumo das vantagens:
- Dependências reais, sem mocks frágeis
- Portabilidade: mesma stack em dev e CI
- Isolamento total: containers são descartados após os testes
- Suporte a múltiplos bancos e serviços

Para evoluir, explore o módulo de mock do testcontainers-go para simular falhas de rede ou use configurações avançadas como testcontainers.WithNetwork para conectar containers entre si.

Referências