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
nameajuda a identificar qual caso falhou. - O loop
forchama a função e compara os resultados. A mensagem de erro seguindo o padrãoF(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:
t.Fatalfpara todo o teste. Um caso falhando impede que os casos seguintes rodem.- 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,
wantErrtem o valor zeronil. Neste casoerrors.Is(nil, nil)retornatrue. - Se a função retorna um erro quando você não esperava,
errors.Is(err, nil)retornafalsee o teste falha imediatamente. errors.Isainda funciona quando o erro é envolvido comfmt.Errorf("...: %w", err). A comparaçãoerr == tc.wantErrnã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 comoApplyDiscount, 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 boolouexpectCall int, e o corpo do loop está cheio deifs, você está escrevendo lógica dentro do teste. Neste caso, considere separar em funções de teste diferentes. - Usar
t.Fatalfora det.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: 50000comGIAM50K).
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 def.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.Isusando um campowantErr 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.Diffao comparar structs complexas. - Quando a tabela começar a conter lógica de controle, é sinal de que você deve separar o teste.