Em Go, a convenção para testar uma função com vários casos é montar uma tabela de entradas e resultados esperados e percorrê-la com um loop. É o padrão chamado de table-driven tests. Junto com subtestes (t.Run) e execução paralela (t.Parallel), ele cobre cenários felizes, casos extremos e caminhos de erro em uma função só, e a suíte roda em uma fração do tempo sequencial.
Neste texto você vai ver a anatomia desse padrão, o que o t.Parallel() faz por dentro e a armadilha de captura da variável de iteração, que durante mais de uma década fez testes paralelos passarem sem testar os casos que deviam.
O problema dos testes repetitivos
Digamos que você implementou uma função de cálculo de desconto. Escrevendo um teste para cada cenário, o arquivo fica assim:
// 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 caso novo duplica o corpo da função, exige um nome longo e repete a asserção. Se a assinatura de CalcularDesconto mudar, você altera dezenas de funções espalhadas pelo arquivo.
A anatomia de um table-driven test
O jeito idiomático separa os dados do teste da lógica de asserção. Uma tabela, que é um slice anônimo de structs, guarda as entradas e os resultados esperados, e um loop executa tudo com o mesmo código:
A estrutura tem quatro partes: a tabela, o loop sobre ela, um subteste por caso e a asserção.
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 uma struct anônima ([]struct{...})? Ela só existe dentro da função de teste, então não polui o pacote com tipos que ninguém mais usa. Adicionar um cenário vira acrescentar uma linha de valores. E os campos nomeados (name: "...", input: ...) deixam claro o que cada valor significa, sem depender da posição.
Subtestes com t.Run()
O t.Run(name, func(t *testing.T)) chegou no Go 1.7 e mudou a forma como o Go reporta resultados. Cada volta do loop vira um subteste independente:
- O relatório é hierárquico. O runner mostra o teste pai, uma barra e o nome do subteste:
TestOperacao/número_positivo_par. - A falha é isolada. Se um subteste falhar com
t.Fatalf, só ele é interrompido. O loop continua e os outros casos da tabela são avaliados. - Dá para rodar um cenário só pelo terminal, com uma expressão regular na flag
-run:
# Executa apenas o subteste de número ímpar
go test -v -run TestOperacao/número_ímpar
t.Errorf ou t.Fatalf
Use t.Fatalf quando continuar não faz sentido. Se a função devia devolver um objeto e devolveu nil, seguir adiante causaria um panic de ponteiro nulo ao acessar user.Email. O t.Fatalf aborta o subteste atual na hora.
Use t.Errorf quando a execução terminou normalmente, mas o valor final difere do esperado. O teste continua e confere os outros campos da mesma resposta, então você vê todos os erros de uma vez no log.
Execução paralela com t.Parallel()
Por padrão, os testes de um pacote Go rodam em sequência. Numa suíte com centenas de testes, ou com parsing de arquivos pesados, criptografia e I/O, isso deixa núcleos da CPU parados.
Chamar t.Parallel() dentro de um subteste instrui o runner a executá-lo em paralelo com os outros testes paralelos:
O ciclo de vida de um teste paralelo
Quando o Go encontra um t.Parallel(), o subteste passa por três fases:
-
RUN: o subteste começa a executar na goroutine do teste pai. -
PAUSE: ao chegar not.Parallel(), ele pausa na hora, entra na lista de testes paralelos pendentes e libera o loop principal para seguir. -
CONT: quando a função pai termina de registrar todos os subtestes e chega ao fim do seu escopo, o runtime acorda os subtestes pausados em goroutines simultâneas, distribuídas entre os núcleos disponíveis.
Dá para ver isso na saída de go test -v:
=== 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
Controlando o paralelismo
O número máximo de testes paralelos simultâneos é controlado pela flag -parallel. O padrão é o valor de GOMAXPROCS, que normalmente é o número de núcleos da CPU:
# Permite até 16 testes paralelos simultâneos
go test -parallel=16 ./...
A armadilha da variável de loop
Durante mais de dez anos, combinar for ... range com t.Parallel() foi fonte de um dos bugs mais traiçoeiros do Go:
O código perigoso é este:
// 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(...)
}
})
}
Antes do Go 1.22, a variável tc era alocada uma única vez para o loop inteiro. A cada iteração o Go só sobrescrevia o mesmo endereço de memória com o próximo item.
O t.Parallel() pausa a goroutine do subteste, e o loop principal continua correndo até o fim da tabela. Quando os subtestes acordam, todos enxergam o valor da última iteração. O teste reporta sucesso em todos os casos, mas na prática rodou o último caso várias vezes e ignorou os primeiros.
As duas soluções
Em Go 1.21 e anteriores, a saída é redeclarar a variável dentro do loop, o que cria uma cópia nova a cada volta:
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)
...
})
}
No Go 1.22 e superiores, a semântica da linguagem mudou: cada iteração do for cria uma nova instância da variável por padrão. A linha tc := tc deixou de ser necessária, e essa classe de falha desapareceu.
Dica: se você mantém uma biblioteca de código aberto que ainda dá suporte ao Go 1.20 ou 1.21, deixe a linha
tc := tcexplícita. Assim a suíte continua correta em qualquer versão do compilador.
Um exemplo completo
Vamos montar uma suíte para um validador de transações financeiras, com erros sentinela e t.Cleanup().
O código de negócio, em 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, em transacao_test.go. O loop abaixo assume Go 1.22 ou superior; em versões anteriores, acrescente tc := tc no início do corpo do for.
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)
}
})
}
}
Checklist antes de dar a suíte por concluída
- Compare erros com
errors.Is, nunca por texto. Umif err.Error() == "texto"quebra na primeira vez que alguém reescrever a mensagem. Valide contra o erro sentinela comerrors.Is(err, ErrAlvo). - Rode com o detector de corridas na integração contínua:
go test -race ./...
Se os subtestes paralelos acessarem uma variável global ou outro recurso compartilhado sem proteção, o detector aponta as linhas em conflito.
- Dê nomes descritivos aos subtestes. O campo
namedeve dizer o que o cenário cobre e por quê, por exemplo"idade negativa deve retornar erro de validação". - Não compartilhe estado entre testes paralelos. Um teste com
t.Parallel()não deve alterar variáveis globais, tabelas de banco sem transação isolada ou o diretório de trabalho. - Chame
t.Helper()na primeira linha de qualquer função auxiliar de asserção. Assim o Go reporta a linha do teste que falhou, e não a linha dentro do utilitário.
Beba Água <3



Top comments (0)