Table-driven tests e paralelismo em Go
Como estruturar cenários de teste em tabelas com subtestes paralelos e por que a captura de variáveis no loop exigia cuidado antes do Go 1.22.
Publicada em · 10 min de leitura
No ecossistema Go, existe uma filosofia explícita sobre como testar software: testes devem ser legíveis, fáceis de estender, livres de mágica e executados com velocidade máxima. Em vez de criar dezenas de funções de teste quase idênticas que mudam apenas um número ou uma string de entrada, a comunidade e a equipe de engenharia do Go padronizaram o padrão Table-Driven Tests (Testes Orientados por Tabelas).
Combinado com subtestes (t.Run) e execução paralela (t.Parallel), este padrão permite cobrir dezenas de casos extremos, cenários felizes e caminhos de erro em uma única função organizada, com tempo de execução reduzido a uma fração do tempo sequencial.
Esta aula disseca a anatomia completa desse padrão, o comportamento do agendador de testes do Go por dentro, a famosa armadilha de captura de variáveis de iteração e as melhores práticas adotadas em projetos de grande porte como Kubernetes e Docker.
O Mapa da Aula
| Parte | Seção | O que você vai dominar |
|---|---|---|
| 1. Fundamentos | 1 e 2 | O problema dos testes repetitivos e a anatomia da tabela de structs |
| 2. Subtestes | 3 e 4 | Isolamento com t.Run(), filtragem no terminal e a diferença entre t.Errorf e t.Fatalf |
| 3. Paralelismo | 5 e 6 | Como t.Parallel() funciona no runtime (o ciclo PAUSE $\to$ CONT) e aceleração de suítes |
| 4. Armadilhas | 7 e 8 | A clássica armadilha da variável de iteração no Go < 1.22 vs a semântica do Go 1.22+ |
| 5. Práticas de Produção | 9 e 10 | Asserção de erros sentinela com errors.Is, limpeza com t.Cleanup() e verificação de concorrência com -race |
Parte 1: Fundamentos e Anatomia
1. O problema antes dos Table-Driven Tests
Imagine que você implementou uma função de cálculo de desconto ou validação de CPF/CNPJ. Se você escrever testes no estilo tradicional de outras linguagens sem parametrização estruturada, o código fica inchado e repetitivo:
// ❌ Abordagem prolixa e difícil de manter
func TestCalcularDesconto_ClienteNovo(t *testing.T) {
got := CalcularDesconto(100.0, "NOVO")
if got != 0.0 {
t.Errorf("esperava 0.0, recebeu %f", got)
}
}
func TestCalcularDesconto_ClienteVIP(t *testing.T) {
got := CalcularDesconto(100.0, "VIP")
if got != 15.0 {
t.Errorf("esperava 15.0, recebeu %f", got)
}
}
func TestCalcularDesconto_ClientePremium(t *testing.T) {
got := CalcularDesconto(100.0, "PREMIUM")
if got != 20.0 {
t.Errorf("esperava 20.0, recebeu %f", got)
}
}
Cada novo caso exige duplicar o corpo da função, inventar um novo nome longo e repetir asserções manuais. Se a assinatura da função CalcularDesconto mudar no futuro, você precisará alterar dezenas de funções espalhadas pelo arquivo.
2. A anatomia de um Table-Driven Test
Em Go, a abordagem idiomática separa os dados dos testes da lógica de asserção. Criamos uma tabela composta por um slice anônimo de structs contendo os parâmetros de entrada e os resultados esperados:
A estrutura padrão de um Table-Driven Test é composta por quatro elementos essenciais:
func TestOperacao(t *testing.T) {
// 1. Definição da tabela de casos
tests := []struct {
name string // Descrição legível do cenário
input int // Parâmetro de entrada
want int // Resultado esperado
wantErr bool // Indica se esperamos um erro neste caso
}{
{
name: "número positivo par",
input: 4,
want: 2,
wantErr: false,
},
{
name: "número ímpar",
input: 5,
want: 0,
wantErr: true,
},
}
// 2. Iteração sobre a tabela
for _, tc := range tests {
// 3. Execução em subteste isolado
t.Run(tc.name, func(t *testing.T) {
got, err := Operacao(tc.input)
// 4. Asserção padronizada
if (err != nil) != tc.wantErr {
t.Fatalf("Operacao(%d) erro = %v, esperava erro? %v", tc.input, err, tc.wantErr)
}
if got != tc.want {
t.Errorf("Operacao(%d) = %d; esperado = %d", tc.input, got, tc.want)
}
})
}
}
Por que usar uma struct anônima ([]struct{...})?
- Escopo restrito: A struct só existe dentro daquela função de teste. Não polui o pacote com tipos auxiliares que ninguém mais usará.
- Adição instantânea de cenários: Adicionar um novo caso de teste se resume a acrescentar uma nova linha de valores literais na struct.
- Nomes nos campos: A sintaxe com chaves nomeadas (
name: "...", input: ...) deixa explícito o que cada valor significa, sem ambiguidade posicional.
Parte 2: Subtestes com t.Run()
3. O poder dos subtestes
Introduzido no Go 1.7, o método t.Run(name, func(t *testing.T)) transformou a forma como o Go reporta resultados. Cada iteração do loop torna-se um subteste independente:
- Relatório hierárquico: O runner exibe o nome do teste pai seguido de barra e o nome do subteste:
TestOperacao/número_positivo_par. - Isolamento de falha: Se o primeiro subteste falhar com
t.Fatalf, apenas aquele subteste é interrompido. O loop continua e todos os outros casos de teste da tabela são avaliados. - Execução seletiva via CLI: Você pode rodar apenas um cenário específico da tabela pelo terminal usando uma expressão regular na flag
-run:# Executa apenas o subteste de número ímpar go test -v -run TestOperacao/número_ímpar
4. t.Errorf vs t.Fatalf: a regra de ouro
- Use
t.Fatalfpara pré-condições críticas: Se a função deveria retornar um objeto e retornounil, ou se falhou em um passo preliminar onde continuar causaria um panic de ponteiro nulo (como acessaruser.Emailquandouser == nil), uset.Fatalf. Ele aborta o subteste atual imediatamente. - Use
t.Errorfpara divergências de valores: Se a execução foi concluída normalmente mas o valor final difere do esperado, uset.Errorf. O teste continuará avaliando outros campos da mesma resposta, permitindo que você veja todos os erros de uma só vez no log.
Parte 3: Execução Paralela com t.Parallel()
5. Como o t.Parallel() opera sob o capô
Por padrão, os testes em Go rodam sequencialmente, um após o outro. Em suítes com centenas de testes ou em cenários que envolvem parsing de arquivos pesados, criptografia ou I/O, a execução sequencial desperdiça os múltiplos núcleos da CPU.
Ao invocar t.Parallel() dentro de um subteste, você instrui o test runner a executar aquela rotina em paralelo com outros testes paralelos:
O ciclo de vida interno de um teste paralelo
Quando o Go encontra uma chamada t.Parallel(), acontece um mecanismo sofisticado de 3 fases:
RUN: O subteste inicia sua execução na goroutine do teste pai.PAUSE: Ao encontrart.Parallel(), o subteste pausa imediatamente, registra seu ponteiro na lista de testes paralelos pendentes e libera o loop principal para continuar.CONT: Assim que a função de teste pai termina de registrar todos os subtestes e atinge o fim do seu escopo, o runtime do Go desperta todos os subtestes pausados em goroutines simultâneas, distribuindo-os entre os núcleos disponíveis do processador.
Podemos constatar esse comportamento inspecionando a saída verbosa do compilador:
=== RUN TestDividir_TableDriven
=== PAUSE TestDividir_TableDriven
=== CONT TestDividir_TableDriven
=== RUN TestDividir_TableDriven/positivo
=== PAUSE TestDividir_TableDriven/positivo
=== RUN TestDividir_TableDriven/negativo
=== PAUSE TestDividir_TableDriven/negativo
=== CONT TestDividir_TableDriven/negativo
=== CONT TestDividir_TableDriven/positivo
--- PASS: TestDividir_TableDriven/positivo (0.00s)
--- PASS: TestDividir_TableDriven/negativo (0.00s)
PASS
6. Controlando o paralelismo
O Go gerencia o número máximo de testes paralelos concorrentes através da flag -parallel, cujo valor padrão é igual a GOMAXPROCS (número de núcleos da CPU):
# Permite até 16 testes paralelos simultâneos
go test -parallel=16 ./...
Parte 4: A Armadilha da Variável de Loop
7. O que acontecia no Go < 1.22
Durante mais de uma década, a combinação de for ... range com t.Parallel() foi a fonte de um dos bugs mais traiçoeiros do Go:
Veja o código perigoso:
// ⚠️ CUIDADO: Comportamento bugado em Go 1.21 e anteriores
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // O subteste pausa aqui!
// Quando for retomado, qual é o valor de tc?
got := Processar(tc.input)
if got != tc.want {
t.Errorf(...)
}
})
}
Por que isso quebrava?
Nas versões do Go anteriores a 1.22, a variável tc era alocada uma única vez para todo o loop. A cada iteração, o Go apenas sobrescrevia a mesma posição de memória com o próximo item.
Como o t.Parallel() pausa a goroutine do subteste, o loop principal continuava correndo em alta velocidade até o final da tabela. Quando os subtestes finalmente acordavam para rodar, todos eles enxergavam o valor da última iteração! O teste reportava que passou em todos os casos, mas na verdade testou o último caso 5 vezes seguidas e ignorou os primeiros.
8. As duas soluções
Solução Histórica (Obrigatória em Go $\le$ 1.21):
Redeclarar a variável no escopo interno do loop para criar uma cópia física nova a cada ciclo:
for _, tc := range tests {
tc := tc // Cria uma cópia local isolada na stack
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Agora tc aponta para a cópia segura
got := Processar(tc.input)
...
})
}
Solução Moderna (Go 1.22+):
No Go 1.22 (e versões superiores), a semântica da linguagem foi aprimorada oficialmente: cada iteração do loop for cria uma nova instância de variável por padrão. Portanto, em Go moderno, a redeclaração tc := tc tornou-se desnecessária, eliminando essa classe inteira de falhas.
Parte 5: Código Prático Completo de Produção
Vamos construir uma suíte profissional completa para um validador de transações financeiras com tratamento de erros sentinela e limpeza com t.Cleanup().
O Código de Negócio (transacao.go):
package financeiro
import (
"errors"
"fmt"
)
var (
ErrValorInvalido = errors.New("o valor da transação deve ser positivo")
ErrContaInvalida = errors.New("número de conta de origem ou destino inválido")
ErrLimiteUltrapassado = errors.New("valor excede o limite diário da conta")
)
type Transacao struct {
ContaOrigem string
ContaDestino string
Valor float64
}
// ValidarTransacao aplica as regras de conformidade bancária.
func ValidarTransacao(t Transacao, limiteDiario float64) error {
if t.Valor <= 0 {
return ErrValorInvalido
}
if len(t.ContaOrigem) < 5 || len(t.ContaDestino) < 5 {
return ErrContaInvalida
}
if t.ContaOrigem == t.ContaDestino {
return fmt.Errorf("%w: conta de origem idêntica ao destino", ErrContaInvalida)
}
if t.Valor > limiteDiario {
return ErrLimiteUltrapassado
}
return nil
}
A Suíte de Testes Table-Driven (transacao_test.go):
package financeiro_test
import (
"errors"
"testing"
"meu-modulo/financeiro"
)
func TestValidarTransacao(t *testing.T) {
t.Parallel() // A suíte pai roda em paralelo com outras do pacote
// Tabela abrangente de cenários de teste
tests := []struct {
name string
transacao financeiro.Transacao
limiteDiario float64
wantErr bool
expectedErr error
}{
{
name: "transação regular aprovada",
transacao: financeiro.Transacao{
ContaOrigem: "12345-6",
ContaDestino: "98765-4",
Valor: 150.00,
},
limiteDiario: 1000.00,
wantErr: false,
expectedErr: nil,
},
{
name: "transação com valor zero",
transacao: financeiro.Transacao{
ContaOrigem: "12345-6",
ContaDestino: "98765-4",
Valor: 0.00,
},
limiteDiario: 1000.00,
wantErr: true,
expectedErr: financeiro.ErrValorInvalido,
},
{
name: "transação com valor negativo",
transacao: financeiro.Transacao{
ContaOrigem: "12345-6",
ContaDestino: "98765-4",
Valor: -50.00,
},
limiteDiario: 1000.00,
wantErr: true,
expectedErr: financeiro.ErrValorInvalido,
},
{
name: "conta de origem com menos de 5 dígitos",
transacao: financeiro.Transacao{
ContaOrigem: "123",
ContaDestino: "98765-4",
Valor: 100.00,
},
limiteDiario: 1000.00,
wantErr: true,
expectedErr: financeiro.ErrContaInvalida,
},
{
name: "transferência para a mesma conta",
transacao: financeiro.Transacao{
ContaOrigem: "12345-6",
ContaDestino: "12345-6",
Valor: 100.00,
},
limiteDiario: 1000.00,
wantErr: true,
expectedErr: financeiro.ErrContaInvalida,
},
{
name: "valor excede o limite diário",
transacao: financeiro.Transacao{
ContaOrigem: "12345-6",
ContaDestino: "98765-4",
Valor: 1500.00,
},
limiteDiario: 1000.00,
wantErr: true,
expectedErr: financeiro.ErrLimiteUltrapassado,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // Cada subteste executa em paralelo
// t.Cleanup executa garantidamente no final deste subteste
t.Cleanup(func() {
// Ideal para fechar arquivos, resetar variáveis ou limpar caches locais
})
err := financeiro.ValidarTransacao(tc.transacao, tc.limiteDiario)
// 1. Verificação da ocorrência de erro
if (err != nil) != tc.wantErr {
t.Fatalf("ValidarTransacao() erro = %v, esperava erro? %v", err, tc.wantErr)
}
// 2. Verificação precisa do tipo/causa do erro com errors.Is
if tc.expectedErr != nil && !errors.Is(err, tc.expectedErr) {
t.Errorf("ValidarTransacao() erro retornado = %v, esperava conter = %v", err, tc.expectedErr)
}
})
}
}
Parte 6: Checklist de Qualidade e Boas Práticas
Antes de considerar sua suíte de testes concluída, valide estes 5 itens:
- Uso de
errors.Isem vez de comparar strings: Nunca façaif err.Error() == "texto". Mensagens mudam com frequência; valide sempre contra erros sentinela comerrors.Is(err, ErrAlvo). - Execução obrigatória com detecção de corridas: Sempre execute seus testes em ambiente de integração com a flag
-race:
Se houver qualquer acesso concorrente inseguro entre seus subtestes paralelos a variáveis globais ou recursos compartilhados, o compilador apontará exatamente as linhas conflitantes.go test -race ./... - Nomes descritivos de subtestes: O campo
namedeve explicar o o quê e o porquê daquele cenário (ex:"idade negativa deve retornar erro de validação"). - Ausência de estado compartilhado em testes paralelos: Testes com
t.Parallel()nunca devem modificar variáveis globais, tabelas estáticas de banco sem transações isoladas ou alterar diretórios de trabalho compartilhados sem isolamento. - Uso de
t.Helper()em funções auxiliares: Se você extrair uma função auxiliar de asserção para fora do teste, declaret.Helper()na primeira linha dela. Isso faz com que o Go reporte a linha exata do teste que falhou, e não a linha interna da função utilitária.
Exercício Prático Proposto
Para consolidar o conhecimento desta aula:
- Implemente uma função
CalcularFrete(pesoKg float64, distanciaKm float64, tipoFrete string) (float64, error). - Crie a suíte
TestCalcularFreteutilizando o padrão Table-Driven Tests com pelo menos 6 cenários (pesos inválidos $\le 0$, distâncias negativas, tipos de frete desconhecidos como"AEREO", frete"ECONOMICO"e frete"EXPRESSO"). - Ative
t.Parallel()tanto no teste pai quanto em cada subteste. - Execute no terminal:
e confirme na saída que os subtestes pausaram e retomaram em concorrência.go test -v -race -run TestCalcularFrete