Table-driven test em Go

1. Visão geral

Quando uma função tem muitas ramificações de lógica, você vai querer testá-la com vários conjuntos diferentes de entrada/saída. Se escrever cada caso como uma função de teste separada, o código de teste vai ter muito código repetido e será difícil de manter.

Table-driven test é o padrão mais comum na comunidade Go. Você descreve os casos de teste como uma “tabela” de dados, e depois usa um único loop para rodar todos.

Depois de ler este artigo, você saberá como escrever table-driven tests com subtestes, verificar erros corretamente, rodar testes em paralelo e comparar structs complexos. Você também saberá quando não usar essa técnica.

2. A função a testar

Vamos usar uma função que calcula o valor final após aplicar um código de desconto em um pedido. A função tem ramificações suficientes para demonstrar bem:

// pricing/discount.go
package pricing

import (
	"errors"
	"strings"
)

var (
	ErrNegativeTotal = errors.New("total não pode ser negativo")
	ErrInvalidCode   = errors.New("código de desconto inválido")
)

// ApplyDiscount retorna o valor em reais após aplicar o código de desconto.
func ApplyDiscount(total int, code string) (int, error) {
	if total < 0 {
		return 0, ErrNegativeTotal
	}

	switch strings.ToUpper(strings.TrimSpace(code)) {
	case "":
		return total, nil
	case "SALE10":
		return total * 90 / 100, nil
	case "GIAM50K":
		if total < 50000 {
			return 0, nil
		}
		return total - 50000, nil
	default:
		return 0, ErrInvalidCode
	}
}

A função tem cinco comportamentos para testar. Isto é: total negativo, sem código, código SALE10, código GIAM50K (dois caminhos) e código inválido. Além disso, o código é normalizado com TrimSpace e ToUpper.

3. Estrutura básica de um table-driven test

3.1. Primeira versão

Um table-driven test tem três partes: declarar a tabela, iterar sobre ela e comparar resultados.

// pricing/discount_test.go
package pricing

import "testing"

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "sem codigo", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 minuscula e espacos", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k sem dinheiro negativo", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		got, err := ApplyDiscount(tc.total, tc.code)
		if err != nil {
			t.Fatalf("%s: erro inesperado: %v", tc.name, err)
		}
		if got != tc.want {
			t.Errorf("%s: ApplyDiscount(%d, %q) = %d, want %d",
				tc.name, tc.total, tc.code, got, tc.want)
		}
	}
}

Explicação:

  • tests é um slice de struct anônimo. Cada elemento é um caso de teste, com entrada (total, code) e saída esperada (want).
  • O campo name ajuda a identificar qual caso falhou.
  • O loop for chama a função e compara os resultados. A mensagem de erro seguindo o padrão F(entrada) = got, want X é uma convenção bem conhecida em Go.

Para adicionar um novo caso, basta adicionar uma linha à tabela. Essa é a maior vantagem dessa técnica.

3.2. Problema da primeira versão

O código acima funciona, mas tem duas desvantagens:

  1. t.Fatalf para todo o teste. Um caso falhando impede que os casos seguintes rodem.
  2. Você não consegue rodar um caso específico quando estiver debugando.

Subtestes resolvem ambos os problemas.

4. Usando subtestes com t.Run

t.Run cria um subteste para cada caso. Cada subteste tem seu próprio *testing.T, então t.Fatal para apenas esse caso.

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "sem codigo", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "sale10 minuscula e espacos", total: 100000, code: "  sale10 ", want: 90000},
		{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
		{name: "giam50k sem dinheiro negativo", total: 30000, code: "GIAM50K", want: 0},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("erro inesperado: %v", err)
			}
			if got != tc.want {
				t.Errorf("ApplyDiscount(%d, %q) = %d, want %d",
					tc.total, tc.code, got, tc.want)
			}
		})
	}
}

Você não precisa mais imprimir tc.name na mensagem de erro, pois Go já anexa o nome do subteste à saída. Execute com -v:

go test ./pricing -v
=== RUN   TestApplyDiscount
=== RUN   TestApplyDiscount/sem_codigo
=== RUN   TestApplyDiscount/sale10
=== RUN   TestApplyDiscount/sale10_minuscula_e_espacos
=== RUN   TestApplyDiscount/giam50k_normal
=== RUN   TestApplyDiscount/giam50k_sem_dinheiro_negativo
--- PASS: TestApplyDiscount (0.00s)
    --- PASS: TestApplyDiscount/sem_codigo (0.00s)
    --- PASS: TestApplyDiscount/sale10 (0.00s)
    --- PASS: TestApplyDiscount/sale10_minuscula_e_espacos (0.00s)
    --- PASS: TestApplyDiscount/giam50k_normal (0.00s)
    --- PASS: TestApplyDiscount/giam50k_sem_dinheiro_negativo (0.00s)
PASS

Você vê que os espaços no nome foram substituídos por _. Para rodar um caso específico, passe uma regex para -run:

go test ./pricing -run 'TestApplyDiscount/giam50k' -v

Este comando roda apenas os dois casos cujos nomes começam com giam50k. Quando um caso falha, a saída fica assim:

--- FAIL: TestApplyDiscount (0.00s)
    --- FAIL: TestApplyDiscount/sale10 (0.00s)
        discount_test.go:24: ApplyDiscount(100000, "SALE10") = 90000, want 85000

Você sabe imediatamente qual caso falhou, em qual linha, e com qual entrada.

Nota: Dê nomes curtos aos casos, únicos e fáceis de digitar novamente no terminal. Se dois casos tiverem o mesmo nome, Go adicionará automaticamente um sufixo #01, tornando mais difícil rodar um caso específico.

5. Verificação de erros em table-driven tests

Casos que retornam erro também devem estar na mesma tabela. Adicione um campo wantErr do tipo error, e compare usando errors.Is:

package pricing

import (
	"errors"
	"testing"
)

func TestApplyDiscount_Errors(t *testing.T) {
	tests := []struct {
		name    string
		total   int
		code    string
		want    int
		wantErr error
	}{
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "total negativo", total: -1, code: "SALE10", wantErr: ErrNegativeTotal},
		{name: "codigo invalido", total: 100000, code: "TET2026", wantErr: ErrInvalidCode},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := ApplyDiscount(tc.total, tc.code)

			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
			}
			if tc.wantErr != nil {
				return // já verificamos o erro, sem necessidade de checar o retorno
			}
			if got != tc.want {
				t.Errorf("got %d, want %d", got, tc.want)
			}
		})
	}
}

Explicação:

  • Para um caso bem-sucedido, wantErr tem o valor zero nil. Neste caso errors.Is(nil, nil) retorna true.
  • Se a função retorna um erro quando você não esperava, errors.Is(err, nil) retorna false e o teste falha imediatamente.
  • errors.Is ainda funciona quando o erro é envolvido com fmt.Errorf("...: %w", err). A comparação err == tc.wantErr não funciona.

Muitos projetos usam wantErr bool para simplificar. Essa abordagem apenas diz se há um erro ou não, não diz que erro é. Você deve preferir comparar com um sentinel error se o package o definir.

6. Executar em paralelo com t.Parallel

Quando casos de teste são lentos (fazem I/O, processamento pesado), você pode rodar os subtestes em paralelo:

func TestApplyDiscount_Parallel(t *testing.T) {
	tests := []struct {
		name  string
		total int
		code  string
		want  int
	}{
		{name: "sem codigo", total: 100000, code: "", want: 100000},
		{name: "sale10", total: 100000, code: "SALE10", want: 90000},
		{name: "giam50k", total: 200000, code: "GIAM50K", want: 150000},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			got, err := ApplyDiscount(tc.total, tc.code)
			if err != nil {
				t.Fatalf("erro inesperado: %v", err)
			}
			if got != tc.want {
				t.Errorf("got %d, want %d", got, tc.want)
			}
		})
	}
}

t.Parallel() diz ao executor de testes que este subteste pode rodar junto com outros subtestes em paralelo. A função pai pausará os subtestes, terminará o loop e depois liberará todos os subtestes para rodar simultaneamente.

A partir de Go 1.22, cada iteração do loop for cria uma nova variável tc, então a closure sempre captura o valor correto. Com Go 1.21 ou anterior, você deve copiar a variável:

for _, tc := range tests {
	tc := tc // necessário para Go < 1.22
	t.Run(tc.name, func(t *testing.T) {
		t.Parallel()
		// ...
	})
}

Se você esquecer a linha tc := tc em versões antigas, todos os subtestes usam o último caso. O teste ainda “passa” mas na verdade está verificando apenas um cenário. Este é um bug muito difícil de encontrar. Verifique a linha go no seu go.mod.

Nota: Use t.Parallel() apenas quando os casos não compartilham estado mutável, como variáveis globais, arquivos temporários compartilhados ou a mesma tabela de banco de dados. Com funções puras como ApplyDiscount, o benefício é praticamente nulo. Não adicione por hábito.

7. Comparando structs complexos com go-cmp

Quando a função retorna uma struct aninhada, o operador != não funciona com structs contendo slices ou maps. E reflect.DeepEqual apenas retorna true/false, sem mostrar onde diferem. A biblioteca go-cmp resolve isso. Adicione ao seu módulo:

go get github.com/google/go-cmp/cmp
// go.mod (trecho)
require github.com/google/go-cmp v0.6.0

Digamos que você tenha uma função que cria uma nota fiscal:

// pricing/invoice.go
package pricing

type Invoice struct {
	Customer string
	Items    []string
	Total    int
}

func BuildInvoice(customer string, items []string, total int, code string) (Invoice, error) {
	final, err := ApplyDiscount(total, code)
	if err != nil {
		return Invoice{}, err
	}
	return Invoice{Customer: customer, Items: items, Total: final}, nil
}

O teste usa cmp.Diff:

package pricing

import (
	"testing"

	"github.com/google/go-cmp/cmp"
)

func TestBuildInvoice(t *testing.T) {
	tests := []struct {
		name     string
		customer string
		items    []string
		total    int
		code     string
		want     Invoice
	}{
		{
			name:     "com desconto sale10",
			customer: "João",
			items:    []string{"camisa", "bone"},
			total:    200000,
			code:     "SALE10",
			want:     Invoice{Customer: "João", Items: []string{"camisa", "bone"}, Total: 180000},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := BuildInvoice(tc.customer, tc.items, tc.total, tc.code)
			if err != nil {
				t.Fatalf("erro inesperado: %v", err)
			}
			if diff := cmp.Diff(tc.want, got); diff != "" {
				t.Errorf("BuildInvoice() divergiu (-want +got):\n%s", diff)
			}
		})
	}
}

cmp.Diff retorna uma string vazia quando os dois valores são iguais. Se diferem, você recebe um diff legível:

BuildInvoice() divergiu (-want +got):
  pricing.Invoice{
  	Customer: "João",
  	Items:    {"camisa", "bone"},
- 	Total:    180000,
+ 	Total:    200000,
  }

O − é o valor esperado, o + é o valor atual. A convenção (-want +got) deve estar clara na mensagem para o leitor não precisar adivinhar.

8. Erros comuns e quando não usar

Alguns erros comuns ao escrever table-driven tests:

  • Tabela gigante com muitos campos booleanos. Quando a struct do teste tem campos como setupMock bool, skipValidation bool ou expectCall int, e o corpo do loop está cheio de ifs, você está escrevendo lógica dentro do teste. Neste caso, considere separar em funções de teste diferentes.
  • Usar t.Fatal fora de t.Run. Um caso falhando vai fazer perder os resultados de todos os casos seguintes.
  • Usar map em vez de slice sem entender as consequências. map[string]struct{...} garante nomes únicos e ordem aleatória de execução, o que ajuda a descobrir dependências ocultas entre casos. Porém, a saída não estará em ordem fixa. Ambas as abordagens são aceitáveis, desde que toda a equipe esteja de acordo.
  • Mensagem de erro sem inputs. Escrever apenas t.Errorf("errado") força quem vai debugar a abrir o código do teste. Sempre imprima a entrada, got e want.
  • Esquecer casos limítrofes. A tabela ajuda você a ver facilmente quais casos faltam. Revise valores zero, negativos, strings vazias e valores bem próximos do limiar (por exemplo, total: 50000 com GIAM50K).

Você não deve usar table-driven test quando:

  • Cada caso precisa de setup muito diferente, como mock retornando dados diferentes ou ordem de chamadas diferente. Forçá-los em uma tabela só torna o teste mais difícil de entender.
  • Você tem apenas um ou dois casos. Uma função de teste simples é mais clara.
  • Você precisa de exploração com entrada aleatória. Neste caso, considere property-based testing usando fuzz tests (func FuzzXxx(f *testing.F)), disponível desde Go 1.18. Você pode usar sua tabela de testes como corpus de seed através de f.Add.

9. Conclusão

Table-driven test ajuda você a compilar muitos cenários de teste em uma tabela de dados e executá-los com um único loop. Combinado com t.Run, cada caso se torna um subteste independente. Você pode rodar um caso específico com -run, e um caso falhando não para os outros.

Pontos-chave a lembrar:

  • Cada caso deve ter um name, entrada e saída esperada.
  • Verifique erros com errors.Is usando um campo wantErr error.
  • Apenas use t.Parallel() quando realmente necessário, e esteja atento à semântica das variáveis do loop antes de Go 1.22.
  • Use cmp.Diff ao comparar structs complexas.
  • Quando a tabela começar a conter lógica de controle, é sinal de que você deve separar o teste.
golangtestestable-driven testtestes unitários