← Voltar para o blog

Testes de Tabela em Go: Guia Definitivo

Aprenda table-driven tests em Go: crie testes de tabela idiomáticos com t.Run(), subtests paralelos, edge cases e exemplos prontos para copiar no seu projeto.

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

  1. Nomeie cada caso claramente — o nome aparece no output do teste e no -run filter
  2. Comece pelo cenário feliz — facilita a leitura ao revisar a tabela
  3. Teste edge cases explicitamente — string vazia, zero, nil, valores limítrofes
  4. Use t.Helper() em funções auxiliares — melhora a rastreabilidade de erros
  5. Prefira t.Fatalf para erros bloqueantes — e t.Errorf para verificações independentes
  6. 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écnicaPropósitoQuando usar
Table-driven testsValidar comportamento esperadoCenários conhecidos, regressão
FuzzingDescobrir bugs desconhecidosParsers, validadores, segurança
BenchmarksMedir performanceOtimizaçã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.