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:

  1. Execução seletiva: Use go test -run TestParsePerson/valid para executar apenas um subteste específico
  2. Relatório granular: Cada subteste aparece individualmente no output com status de passou/falhou
  3. 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.Errorf em vez de t.Fatalf para 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