Pular para o conteúdo

Trilha de Go · parte 17 de 37

Testes de integração com Testcontainers em Go

Como subir PostgreSQL, Redis ou Kafka reais em contêineres Docker durante os testes, de forma programática, isolada e descartável.

Publicada em · 8 min de leitura

Tamanho da fonte:
Largura da coluna:

Durante anos, desenvolvedores backend enfrentaram um dilema clássico ao testar código que interage com bancos de dados e mensageria:

  1. Usar mocks de SQL (como go-sqlmock): Os testes rodam rápido, mas não testam o banco de verdade. Mocks apenas validam se a string da query bate caractere por caractere; eles não descobrem se você cometeu um erro de sintaxe SQL, se violou uma chave estrangeira ou se o tipo JSONB quebrou.
  2. Substituir por SQLite em memória: SQLite não suporta recursos cruciais do PostgreSQL (como arrays, UUID nativo, cláusulas ON CONFLICT DO UPDATE, índices parciais e controle de concorrência com MVCC).
  3. Manter um banco de dados local fixo compartilhado: Gera conflitos de concorrência entre desenvolvedores, acúmulo de dados sujos entre execuções e o clássico erro de porta já em uso (bind: address already in use: 5432).

A solução definitiva adotada pela indústria moderna de software é o Testcontainers para Go (testcontainers-go).

Esta aula ensina como utilizar o Testcontainers para instanciar contêineres Docker reais (PostgreSQL, Redis, Kafka) de forma totalmente programática, isolada e efêmera, diretamente dentro do ciclo de vida dos seus testes em Go.


O Mapa da Aula

ParteSeçãoO que você vai dominar
1. Fundamentos1 e 2O que é o Testcontainers, por que portas dinâmicas são vitais e o papel do Moby Ryuk
2. O Módulo Postgres3 e 4Instanciação com modules/postgres, extração da Connection String e estratégias de espera (Wait Strategies)
3. Ciclo de Vida com Suíte5 e 6Integração com suite.Suite, isolamento de dados com SetupTest e limpeza garantida com Terminate
4. Código de Produção7 e 8Implementação prática de um repositório com pgxpool e PostgreSQL 16 real
5. Práticas Corporativas9 e 10Isolando testes rápidos e lentos com testing.Short(), execução em CI e checklist de qualidade

Pré-requisitos recomendados: Suíte de Testes com Testify e Mocks e Dublês de Teste.


Parte 1: A Arquitetura do Testcontainers para Go

1. Como o Testcontainers funciona sob o capô

O Testcontainers é uma biblioteca que se comunica diretamente com o Docker Engine API local ou do servidor de CI através do socket do Docker (/var/run/docker.sock):

arquitetura testcontainers
figura 1 · arquitetura testcontainers

O ciclo operacional segue passos rigorosos:

  1. Ao iniciar a suíte de testes, o Go solicita a criação de um contêiner baseado em uma imagem oficial (ex: postgres:16-alpine).
  2. Portas Dinâmicas e Aleatórias: O Testcontainers nunca faz bind de portas fixas no host. Em vez de usar a porta 5432, o Docker atribui uma porta alta livre aleatória (por exemplo, 55001 -> 5432). Isso permite rodar múltiplos testes paralelos e múltiplos pipelines de CI na mesma máquina sem nenhum conflito de porta!
  3. O Go aguarda o banco sinalizar que está pronto para receber conexões.
  4. O teste executa queries SQL reais sobre o banco de dados em memória do contêiner.
  5. Ao final, o contêiner é destruído, garantindo que nenhum lixo de dados sobreviva entre suítes.

2. O Guardião contra Contêineres Órfãos: Moby Ryuk

Um dos maiores temores ao rodar contêineres em testes é o vazamento de recursos: e se o desenvolvedor cancelar o teste no meio com Ctrl+C ou se o processo de Go for morto abruptamente com um panic ou kill -9? O contêiner ficaria rodando indefinidamente consumindo memória da máquina?

O Testcontainers resolve isso com um componente engenhoso chamado Moby Ryuk (testcontainers/ryuk):

  • O Ryuk é um contêiner auxiliar minúsculo que abre uma conexão socket bidirecional com o processo do seu teste em Go.
  • Se a conexão com o teste Go cair por qualquer motivo (mesmo um encerramento forçado do sistema), o Ryuk detecta o encerramento do cliente e destrói imediatamente todos os contêineres e volumes criados durante aquela sessão.

Parte 2: O Módulo Oficial modules/postgres

O ecossistema testcontainers-go disponibiliza módulos prontos e especializados para as ferramentas mais populares de infraestrutura. Para o PostgreSQL:

go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres

3. Instanciando o Contêiner

Para subir um PostgreSQL em Go:

ctx := context.Background()

pgContainer, err := postgres.Run(ctx,
    "postgres:16-alpine",
    postgres.WithDatabase("testdb"),
    postgres.WithUsername("testuser"),
    postgres.WithPassword("testpass"),
)
if err != nil {
    log.Fatalf("falha ao iniciar container postgres: %v", err)
}
defer func() {
    _ = pgContainer.Terminate(ctx) // Destrói o container no final
}()

4. Obtendo a Connection String Dinâmica

Como a porta mapeada pelo Docker é aleatória, você nunca deve escrever localhost:5432 no seu código de teste. O próprio objeto do contêiner fornece o método ConnectionString:

connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
// Retorna algo como: postgres://testuser:testpass@localhost:55001/testdb?sslmode=disable

Você passa essa string diretamente para o database/sql ou para o pool de conexões moderno pgxpool.New(ctx, connStr).


Parte 3: Ciclo de Vida com Testify Suite

Subir um contêiner Docker leva entre 1 e 3 segundos. Por essa razão, nunca suba um contêiner para cada função de teste individual.

O padrão arquitetural correto é associar o Testcontainers aos hooks de ciclo de vida de uma Suíte de Testes com Testify:

ciclo vida testcontainers suite
figura 2 · ciclo vida testcontainers suite
  1. SetupSuite(): Sobe o contêiner uma única vez, extrai a connection string, executa as migrações (criação de tabelas e índices) e abre o pool de conexões.
  2. SetupTest(): Antes de cada método de teste, executa um comando rápido TRUNCATE TABLE para limpar os dados inseridos pelo teste anterior, garantindo isolamento total.
  3. TearDownSuite(): Fecha o pool de conexões e invoca pgContainer.Terminate(ctx) para derrubar o contêiner.

Parte 4: Código Prático Completo de Produção

Abaixo está a implementação real de um repositório de clientes corporativo utilizando o driver de alta performance pgx e testado com PostgreSQL 16 real.

O Código de Domínio e Repositório (cliente.go):

package repository

import (
	"context"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

type Cliente struct {
	ID        string
	Nome      string
	Email     string
	CreatedAt time.Time
}

type ClienteRepository struct {
	pool *pgxpool.Pool
}

func NovoClienteRepository(pool *pgxpool.Pool) *ClienteRepository {
	return &ClienteRepository{pool: pool}
}

func (r *ClienteRepository) Inserir(ctx context.Context, c *Cliente) error {
	query := `INSERT INTO clientes (id, nome, email, created_at) VALUES ($1, $2, $3, $4)`
	_, err := r.pool.Exec(ctx, query, c.ID, c.Nome, c.Email, c.CreatedAt)
	if err != nil {
		return fmt.Errorf("erro ao inserir cliente: %w", err)
	}
	return nil
}

func (r *ClienteRepository) BuscarPorEmail(ctx context.Context, email string) (*Cliente, error) {
	query := `SELECT id, nome, email, created_at FROM clientes WHERE email = $1`
	var c Cliente
	err := r.pool.QueryRow(ctx, query, email).Scan(&c.ID, &c.Nome, &c.Email, &c.CreatedAt)
	if err != nil {
		return nil, fmt.Errorf("cliente não encontrado: %w", err)
	}
	return &c, nil
}

A Suíte de Integração com Testcontainers (cliente_test.go):

package repository_test

import (
	"context"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/stretchr/testify/suite"
	tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"

	"meu-modulo/repository"
)

type ClienteRepositoryTestSuite struct {
	suite.Suite
	ctx       context.Context
	container *tcpostgres.PostgresContainer
	pool      *pgxpool.Pool
	repo      *repository.ClienteRepository
}

// SetupSuite: executado uma única vez antes de todos os testes
func (s *ClienteRepositoryTestSuite) SetupSuite() {
	s.ctx = context.Background()

	// 1. Sobe container efêmero de Postgres 16 oficial
	pgContainer, err := tcpostgres.Run(s.ctx,
		"postgres:16-alpine",
		tcpostgres.WithDatabase("testdb"),
		tcpostgres.WithUsername("testuser"),
		tcpostgres.WithPassword("testpass"),
	)
	s.Require().NoError(err, "falha ao inicializar container do Postgres")
	s.container = pgContainer

	// 2. Extrai a connection string dinâmica (porta host atribuída aleatoriamente)
	connStr, err := s.container.ConnectionString(s.ctx, "sslmode=disable")
	s.Require().NoError(err)

	// 3. Conecta via pgxpool
	pool, err := pgxpool.New(s.ctx, connStr)
	s.Require().NoError(err)
	s.pool = pool

	// 4. Aguarda estabilização do banco de dados (resiliência ativa)
	var pingErr error
	for i := 0; i < 15; i++ {
		pingErr = s.pool.Ping(s.ctx)
		if pingErr == nil {
			break
		}
		time.Sleep(200 * time.Millisecond)
	}
	s.Require().NoError(pingErr, "banco deve responder ao ping")

	// 5. Executa DDL de criação da tabela de produção
	ddl := `
	CREATE TABLE IF NOT EXISTS clientes (
		id VARCHAR(50) PRIMARY KEY,
		nome VARCHAR(100) NOT NULL,
		email VARCHAR(150) UNIQUE NOT NULL,
		created_at TIMESTAMP NOT NULL
	);`
	_, err = s.pool.Exec(s.ctx, ddl)
	s.Require().NoError(err)

	s.repo = repository.NovoClienteRepository(s.pool)
}

// SetupTest: executado antes de CADA método para garantir tabelas limpas
func (s *ClienteRepositoryTestSuite) SetupTest() {
	_, err := s.pool.Exec(s.ctx, "TRUNCATE TABLE clientes;")
	s.Require().NoError(err)
}

// TearDownSuite: derruba o container garantindo zero lixo na máquina
func (s *ClienteRepositoryTestSuite) TearDownSuite() {
	if s.pool != nil {
		s.pool.Close()
	}
	if s.container != nil {
		_ = s.container.Terminate(s.ctx)
	}
}

// Teste 1: Inserção e recuperação com banco de dados real
func (s *ClienteRepositoryTestSuite) TestInserirEBuscarPorEmail() {
	cliente := &repository.Cliente{
		ID:        "c-001",
		Nome:      "Mariana Costa",
		Email:     "mariana@empresa.com",
		CreatedAt: time.Now().Truncate(time.Microsecond),
	}

	err := s.repo.Inserir(s.ctx, cliente)
	s.Require().NoError(err)

	encontrado, err := s.repo.BuscarPorEmail(s.ctx, "mariana@empresa.com")
	s.Require().NoError(err)
	s.Require().NotNil(encontrado)

	s.Assert().Equal("c-001", encontrado.ID)
	s.Assert().Equal("Mariana Costa", encontrado.Nome)
	s.Assert().Equal("mariana@empresa.com", encontrado.Email)
}

// Teste 2: Consulta de registro inexistente
func (s *ClienteRepositoryTestSuite) TestBuscarNaoExistente() {
	_, err := s.repo.BuscarPorEmail(s.ctx, "naoexiste@email.com")
	s.Require().Error(err)
	s.Assert().Contains(err.Error(), "cliente não encontrado")
}

// Runner oficial: permite pular testes lentos de infraestrutura via -short
func TestClienteRepositoryTestSuite(t *testing.T) {
	if testing.Short() {
		t.Skip("Pulando teste de integração pesado no modo -short")
	}
	suite.Run(t, new(ClienteRepositoryTestSuite))
}

Parte 5: Práticas Corporativas e CI/CD

9. Separando Testes Rápidos e Lentos com -short

Testes unitários puros devem rodar em milissegundos. Testes de integração com contêineres Docker levam alguns segundos.

Para que o desenvolvedor tenha feedback instantâneo enquanto escreve código no dia a dia, utilizamos a convenção nativa testing.Short():

if testing.Short() {
    t.Skip("Pulando teste de integração no modo -short")
}
  • No loop rápido de desenvolvimento local:
    go test -short ./... # Roda apenas testes unitários em 0.2 segundos
  • Antes de abrir Pull Request e na esteira de CI/CD:
    go test -v ./...    # Roda a suíte completa incluindo Testcontainers

10. Checklist de Qualidade com Testcontainers

  1. Use imagens leves: Prefira sempre imagens Alpine (ex: postgres:16-alpine, redis:7-alpine) para diminuir drasticamente o tempo de download inicial na esteira de CI.
  2. Reutilize o contêiner na mesma suíte (SetupSuite): Suba 1 contêiner por suíte de repositório e limpe as tabelas com TRUNCATE TABLE no SetupTest(). Isso reduz o tempo da suíte de minutos para meros 2 a 3 segundos.
  3. Não dependa de conexões síncronas cegas: Após o contêiner subir, execute um laço de ping com retry antes de rodar os scripts DDL, pois bancos de dados podem levar algumas centenas de milissegundos para concluir a rotina interna de initdb.
  4. Sempre chame container.Terminate(ctx) no TearDownSuite: Mesmo com o Ryuk protegendo contra órfãos, liberar a memória imediatamente ao término da suíte mantém a máquina limpa.

Exercício Prático Proposto

  1. Crie um contêiner efêmero de Redis utilizando o módulo github.com/testcontainers/testcontainers-go/modules/redis.
  2. Conecte um cliente go-redis/v9 utilizando a connection string dinâmica retornada pelo contêiner.
  3. Implemente uma suíte de testes validando:
    • Gravação de chave com tempo de expiração TTL.
    • Leitura de chave inexistente retornando redis.Nil.
  4. Adicione t.Skip caso testing.Short() esteja ativado.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?