Table-driven tests
1. Introdução aos Table-driven Tests
Table-driven tests são uma técnica de teste onde os casos de teste são definidos como uma lista (tabela) de structs, cada um contendo entradas, saídas esperadas e um nome descritivo. Em Go, essa abordagem tornou-se um padrão amplamente adotado, sendo recomendada até mesmo na documentação oficial da linguagem.
Diferentemente dos testes tradicionais que usam múltiplos blocos if ou repetição de código para cada cenário, os table-driven tests centralizam a lógica de teste em um único loop. Isso elimina duplicação, reduz erros de copiar-e-colar e torna a adição de novos casos trivial.
Os principais benefícios incluem:
- Legibilidade: A estrutura tabular permite visualizar rapidamente todos os cenários testados
- Manutenção: Adicionar um novo caso requer apenas mais uma linha na tabela
- Cobertura: Facilita testar casos de borda sem aumentar significativamente o código
2. Estrutura Básica de um Table-driven Test
A anatomia de um table-driven test segue um padrão consistente. Vamos começar com um exemplo simples: testar uma função de soma.
func Sum(a, b int) int {
return a + b
}
func TestSum(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{name: "positive numbers", a: 2, b: 3, expected: 5},
{name: "negative numbers", a: -1, b: -2, expected: -3},
{name: "zero values", a: 0, b: 0, expected: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Sum(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Sum(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.expected)
}
})
}
}
A tabela é um slice de structs anônimos. Cada linha representa um caso de teste com campos para entrada (a, b), saída esperada (expected) e um nome descritivo (name). A iteração com range e t.Run cria subtestes independentes.
3. Nomeação e Organização dos Casos de Teste
A escolha de nomes descritivos para o campo name é crucial. Em vez de nomes genéricos como "caso 1", use descrições que indiquem o cenário sendo testado. Isso facilita a depuração quando um teste falha, pois a mensagem de erro incluirá o nome do subteste.
Organize os casos logicamente:
- Casos felizes: Entradas válidas que produzem resultados esperados
- Casos de erro: Entradas que devem disparar erros ou comportamentos específicos
- Casos de borda: Valores limites, vazios, nulos ou extremos
tests := []struct {
name string
input string
expected int
wantErr bool
}{
// Casos felizes
{name: "valid input", input: "42", expected: 42, wantErr: false},
{name: "negative number", input: "-10", expected: -10, wantErr: false},
// Casos de borda
{name: "zero", input: "0", expected: 0, wantErr: false},
{name: "empty string", input: "", expected: 0, wantErr: true},
// Casos de erro
{name: "invalid format", input: "abc", expected: 0, wantErr: true},
{name: "overflow", input: "99999999999999999999", expected: 0, wantErr: true},
}
4. Tratamento de Erros e Validação em Table-driven Tests
Funções que retornam error junto com o valor exigem uma estrutura um pouco diferente. Inclua campos want e wantErr (ou errExpected) para validar ambos os retornos.
func ParseInt(s string) (int, error) {
if s == "" {
return 0, fmt.Errorf("empty string")
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("invalid input: %s", s)
}
return n, nil
}
func TestParseInt(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "valid number", input: "42", want: 42, wantErr: false},
{name: "empty string", input: "", want: 0, wantErr: true},
{name: "non-numeric", input: "abc", want: 0, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseInt(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseInt(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ParseInt(%q) = %d; want %d", tt.input, got, tt.want)
}
})
}
}
Para resultados complexos (slices, maps, structs), use reflect.DeepEqual para comparação:
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %v; want %v", got, tt.want)
}
5. Testes com Dados Complexos e Múltiplas Saídas
Quando a função retorna múltiplos valores ou tipos complexos, a tabela pode conter structs aninhadas ou slices. Veja um exemplo de parsing que retorna uma struct e erro:
type Person struct {
Name string
Age int
}
func ParsePerson(data string) (Person, error) {
parts := strings.Split(data, ",")
if len(parts) != 2 {
return Person{}, fmt.Errorf("invalid format")
}
age, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return Person{}, fmt.Errorf("invalid age")
}
return Person{Name: strings.TrimSpace(parts[0]), Age: age}, nil
}
func TestParsePerson(t *testing.T) {
tests := []struct {
name string
input string
want Person
wantErr bool
}{
{
name: "valid person",
input: "Alice,30",
want: Person{Name: "Alice", Age: 30},
},
{
name: "invalid format",
input: "Bob",
wantErr: true,
},
{
name: "invalid age",
input: "Charlie,abc",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePerson(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParsePerson(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParsePerson(%q) = %v; want %v", tt.input, got, tt.want)
}
})
}
}
6. Subtestes e Isolamento com t.Run
O método t.Run cria subtestes nomeados que são executados como testes independentes. Isso oferece várias vantagens:
- Execução seletiva: Use
go test -run TestParsePerson/validpara executar apenas um subteste específico - Relatório granular: Cada subteste aparece individualmente no output com status de passou/falhou
- Isolamento: Um subteste que falha não impede a execução dos demais
Para paralelismo controlado, adicione t.Parallel() dentro do subteste:
for _, tt := range tests {
tt := tt // Importante em versões anteriores ao Go 1.22
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// lógica do teste
})
}
Nota sobre clausura: Antes do Go 1.22, a variável tt era reutilizada na iteração, causando bugs sutis. A linha tt := tt criava uma cópia local. A partir do Go 1.22, isso não é mais necessário, pois cada iteração cria uma nova variável.
7. Padrões Avançados e Boas Práticas
Tabelas dinâmicas geradas por funções auxiliares
Para cenários complexos, funções geradoras podem criar casos de teste programaticamente:
func generateTestCases() []struct {
name string
input int
want bool
} {
var cases []struct {
name string
input int
want bool
}
for i := -5; i <= 5; i++ {
cases = append(cases, struct {
name string
input int
want bool
}{
name: fmt.Sprintf("is_even_%d", i),
input: i,
want: i%2 == 0,
})
}
return cases
}
Uso de t.Helper() para melhorar mensagens de falha
Crie funções auxiliares de comparação e marque-as com t.Helper():
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
Evitando armadilhas com variáveis de iteração
Em versões anteriores ao Go 1.22, ao usar goroutines ou closures dentro de loops range, a variável de iteração é compartilhada. A solução é criar uma cópia:
for _, tt := range tests {
tt := tt // Cria cópia local (necessário apenas em Go < 1.22)
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// usa tt copiado
})
}
Boas práticas adicionais:
- Mantenha a tabela de testes próxima à função que testa
- Use nomes de campos explícitos na inicialização da struct para melhor legibilidade
- Prefira
t.Errorfem vez det.Fatalfpara que todos os subtestes executem - Documente casos de borda não óbvios com comentários na tabela
Table-driven tests são mais que um padrão — são uma filosofia de teste que valoriza clareza, manutenibilidade e cobertura sistemática. Ao adotá-los, você escreve testes que são tão legíveis quanto o código que testam.
Referências
- Go Testing: Table-driven tests (Documentação Oficial) — Guia oficial da comunidade Go sobre table-driven tests, com exemplos e boas práticas
- Testing Techniques (Documentação Oficial do Golang) — Seção sobre testes no tutorial oficial de Go, incluindo exemplos de table-driven tests
- How to Write Table-Driven Tests in Go (The Go Blog) — Artigo do blog oficial de Go sobre subtests e table-driven tests, com ênfase em
t.Run - Advanced Testing with Go (Dave Cheney) — Post do renomado desenvolvedor Go Dave Cheney sobre por que preferir table-driven tests
- Table-Driven Tests in Go: A Complete Guide (Medium - Golang Cafe) — Guia completo com exemplos avançados, incluindo testes paralelos e tratamento de erros
- Testing in Go: Table-Driven Tests (DigitalOcean Tutorial) — Tutorial prático da DigitalOcean com exemplos passo a passo de table-driven tests
- Go 1.22 Release Notes: Loop Variable Changes — Notas oficiais sobre a mudança no escopo de variáveis de iteração, relevante para evitar armadilhas em table-driven tests