Table-driven tests são o padrão idiomático de testes em Go. Se você já leu o código fonte da standard library, percebeu que praticamente todo teste segue esse formato: um slice de structs com casos de teste, iterado com t.Run(). Não é por acaso — essa abordagem é concisa, extensível e fácil de manter.
Neste guia, você vai dominar table-driven tests desde a estrutura básica até subtests paralelos, passando por exemplos reais com handlers HTTP, validadores e parsers.
Por que Table-Driven Tests São o Padrão em Go
Testes tradicionais repetem muito código. Para cada cenário, você escreve uma função ou bloco separado com setup, execução e asserção. Com table-driven tests, os dados ficam separados da lógica de teste:
- Menos duplicação: a lógica de asserção é escrita uma vez
- Fácil de estender: adicionar um caso é adicionar uma linha na tabela
- Legibilidade: cada caso documenta o cenário no próprio nome
- Subtests nativos:
t.Run()gera subtests nomeados no output
Se você está começando com testes em Go, veja primeiro o guia de testes para entender o básico do pacote testing. Table-driven tests são o próximo passo natural.
Estrutura Básica
A estrutura clássica usa um slice anônimo de structs com campos para input, output esperado e nome do caso:
package mathutil
import "testing"
func TestSoma(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{name: "positivos", a: 2, b: 3, expected: 5},
{name: "zero", a: 0, b: 0, expected: 0},
{name: "negativos", a: -1, b: -2, expected: -3},
{name: "misto", a: -5, b: 10, expected: 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Soma(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Soma(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.expected)
}
})
}
}
Execute e veja cada caso como um subtest nomeado:
go test -v -run TestSoma
Saída:
=== RUN TestSoma
=== RUN TestSoma/positivos
=== RUN TestSoma/zero
=== RUN TestSoma/negativos
=== RUN TestSoma/misto
--- PASS: TestSoma (0.00s)
A variável tt (abreviação de “table test”) é convenção na comunidade Go. O campo name é passado para t.Run(), criando subtests que podem ser executados individualmente com -run TestSoma/negativos.
Testando Erros e Edge Cases
Em funções que retornam error, adicione um campo wantErr à tabela:
package parser
import (
"errors"
"testing"
)
func TestParsePort(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "porta válida", input: "8080", want: 8080, wantErr: false},
{name: "porta mínima", input: "1", want: 1, wantErr: false},
{name: "porta máxima", input: "65535", want: 65535, wantErr: false},
{name: "zero inválido", input: "0", want: 0, wantErr: true},
{name: "negativo", input: "-1", want: 0, wantErr: true},
{name: "acima do limite", input: "65536", want: 0, wantErr: true},
{name: "não numérico", input: "abc", want: 0, wantErr: true},
{name: "string vazia", input: "", want: 0, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePort(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("ParsePort(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("ParsePort(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
Essa abordagem de tratamento de erros nos testes é idiomática: use t.Fatalf para erros que impedem a continuação e t.Errorf para falhas que ainda permitem verificar outros campos.
Subtests Paralelos
Para acelerar suítes com muitos casos, execute subtests em paralelo com t.Parallel(). Atenção ao closure — capture a variável do loop:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
valid bool
}{
{name: "válido simples", email: "[email protected]", valid: true},
{name: "com subdomínio", email: "[email protected]", valid: true},
{name: "sem arroba", email: "userexample.com", valid: false},
{name: "sem domínio", email: "user@", valid: false},
{name: "vazio", email: "", valid: false},
{name: "com espaços", email: "user @example.com", valid: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ValidateEmail(tt.email)
if got != tt.valid {
t.Errorf("ValidateEmail(%q) = %v, want %v", tt.email, got, tt.valid)
}
})
}
}
Desde o Go 1.22, a variável de loop tem escopo por iteração, eliminando o antigo problema de captura de closure. Se você usa Go 1.22+, não precisa mais da linha tt := tt antes do t.Run.
Testando Handlers HTTP
Table-driven tests brilham ao testar APIs REST. Use httptest.NewRecorder() para simular requisições:
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
tests := []struct {
name string
method string
wantStatus int
wantBody string
}{
{name: "GET retorna 200", method: http.MethodGet, wantStatus: http.StatusOK, wantBody: `{"status":"ok"}`},
{name: "POST retorna 405", method: http.MethodPost, wantStatus: http.StatusMethodNotAllowed, wantBody: ""},
{name: "PUT retorna 405", method: http.MethodPut, wantStatus: http.StatusMethodNotAllowed, wantBody: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
if tt.wantBody != "" && rec.Body.String() != tt.wantBody {
t.Errorf("body = %q, want %q", rec.Body.String(), tt.wantBody)
}
})
}
}
Para handlers que dependem de context, adicione um campo ctx à tabela ou use um helper que injeta valores no contexto de cada requisição.
Tabelas com Funções de Comparação
Quando a comparação é complexa (structs com campos não exportados, slices, maps), use funções de comparação na própria tabela:
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
check func(t *testing.T, cfg *Config, err error)
}{
{
name: "config válida",
input: `{"port": 8080, "debug": true}`,
check: func(t *testing.T, cfg *Config, err error) {
t.Helper()
if err != nil {
t.Fatalf("erro inesperado: %v", err)
}
if cfg.Port != 8080 {
t.Errorf("port = %d, want 8080", cfg.Port)
}
},
},
{
name: "JSON inválido",
input: `{invalid}`,
check: func(t *testing.T, cfg *Config, err error) {
t.Helper()
if err == nil {
t.Fatal("esperava erro, got nil")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := ParseConfig([]byte(tt.input))
tt.check(t, cfg, err)
})
}
}
Note o uso de t.Helper() — ele faz com que, ao falhar, o Go reporte a linha de quem chamou a função de check, não a linha interna do helper. Esse padrão é essencial para manter as mensagens de erro úteis.
Convenções e Boas Práticas
- Nomeie cada caso claramente — o nome aparece no output do teste e no
-runfilter - Comece pelo cenário feliz — facilita a leitura ao revisar a tabela
- Teste edge cases explicitamente — string vazia, zero, nil, valores limítrofes
- Use
t.Helper()em funções auxiliares — melhora a rastreabilidade de erros - Prefira
t.Fatalfpara erros bloqueantes — et.Errorfpara verificações independentes - Mantenha a tabela ordenada — agrupe por tipo de cenário (válidos, inválidos, edge cases)
Para projetos maiores, combine table-driven tests com testes de integração via Testcontainers e fuzzing nativo para cobertura completa.
Table-Driven Tests vs Fuzzing vs Benchmarks
| Técnica | Propósito | Quando usar |
|---|---|---|
| Table-driven tests | Validar comportamento esperado | Cenários conhecidos, regressão |
| Fuzzing | Descobrir bugs desconhecidos | Parsers, validadores, segurança |
| Benchmarks | Medir performance | Otimização, comparação de abordagens |
Table-driven tests são a base da pirâmide de testes em Go. Use generics para criar helpers de teste reutilizáveis que funcionem com qualquer tipo, e slog para logging estruturado que facilite o debug quando um teste falha em CI.
Exemplo Real: Validador de CPF
Um exemplo completo que combina tudo — edge cases, erros e cenários reais:
func TestValidaCPF(t *testing.T) {
tests := []struct {
name string
cpf string
wantErr bool
}{
{name: "CPF válido formatado", cpf: "123.456.789-09", wantErr: false},
{name: "CPF válido sem formato", cpf: "12345678909", wantErr: false},
{name: "todos iguais", cpf: "111.111.111-11", wantErr: true},
{name: "dígito errado", cpf: "123.456.789-00", wantErr: true},
{name: "curto demais", cpf: "1234", wantErr: true},
{name: "longo demais", cpf: "123456789012345", wantErr: true},
{name: "vazio", cpf: "", wantErr: true},
{name: "letras", cpf: "abc.def.ghi-jk", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidaCPF(tt.cpf)
if (err != nil) != tt.wantErr {
t.Errorf("ValidaCPF(%q) error = %v, wantErr %v", tt.cpf, err, tt.wantErr)
}
})
}
}
Esse padrão se aplica a qualquer validação — emails, CEPs, telefones. A tabela é a documentação viva dos cenários aceitos e rejeitados.
Organizando Testes em Projetos Maiores
Em projetos com clean architecture ou múltiplos packages, mantenha os testes no mesmo package (_test.go ao lado do código). Para testes que precisam de interfaces mockadas, crie um pacote testutil com helpers compartilhados.
Se você usa Go embed para embutir fixtures de teste (arquivos JSON, SQL, etc.), combine com table-driven tests para iterar sobre múltiplos arquivos de input automaticamente.
Próximos Passos
Table-driven tests são a fundação de código Go testável e mantível. Combine com fuzzing para encontrar bugs que você não imaginou, Testcontainers para testes de integração com bancos reais, e PGO para otimizar o código que seus testes validam.
Quer ver como outras linguagens lidam com testes? Confira como Rust implementa testes integrados ao compilador com macros como assert_eq!, ou como Python usa pytest para parametrização de testes — o equivalente de table-driven tests no ecossistema Python.
FAQ
O que são table-driven tests em Go?
Table-driven tests são um padrão idiomático onde você define casos de teste como um slice de structs e itera sobre eles com t.Run(). Cada struct contém inputs, outputs esperados e um nome descritivo. É o padrão mais usado na standard library do Go.
Qual a diferença entre t.Errorf e t.Fatalf em testes de tabela?
t.Errorf registra a falha mas continua executando o teste, permitindo verificar outros campos. t.Fatalf para a execução do subtest imediatamente. Use t.Fatalf para erros que invalidam as verificações seguintes (como um erro inesperado) e t.Errorf para comparações independentes.
Como rodar apenas um caso específico de um table-driven test?
Use a flag -run com o nome do teste e do subteste: go test -run TestSoma/negativos. O nome do subteste é o valor passado para t.Run(). Regex é suportado, então go test -run TestSoma/ roda todos os subtests de TestSoma.
Posso usar table-driven tests com t.Parallel()?
Sim. Chame t.Parallel() dentro do t.Run() para executar subtests em paralelo. Desde o Go 1.22, a variável de loop tem escopo por iteração, então não é mais necessário capturá-la com tt := tt antes do closure.
Table-driven tests substituem fuzzing?
Não. Table-driven tests validam cenários conhecidos e documentados. Fuzzing descobre bugs com inputs aleatórios que você não imaginou. Use ambos: table-driven para regressão e comportamento esperado, fuzzing para robustez e segurança.