Subtests e helpers de teste
1. Introdução aos Subtests em Go
Subtests são uma funcionalidade poderosa introduzida no Go 1.7 que permite organizar testes de forma hierárquica dentro de uma única função de teste. Em vez de criar múltiplas funções TestXxx para cada cenário, você pode agrupar casos relacionados usando t.Run().
Por que usar subtests?
- Organização: Agrupa cenários relacionados em uma estrutura clara
- Isolamento: Cada subtest executa independentemente, permitindo falhas isoladas
- Legibilidade: Nomes descritivos tornam a intenção do teste explícita
- Execução seletiva: Execute apenas subtests específicos sem rodar a suíte inteira
Diferença fundamental: Em testes tradicionais, uma falha interrompe toda a função. Com subtests, você pode continuar executando outros cenários mesmo após uma falha, pois cada t.Run() cria um escopo independente.
Sintaxe básica:
func TestCalculadora(t *testing.T) {
t.Run("soma", func(t *testing.T) {
resultado := somar(2, 3)
if resultado != 5 {
t.Errorf("esperado 5, obtido %d", resultado)
}
})
t.Run("subtracao", func(t *testing.T) {
resultado := subtrair(5, 3)
if resultado != 2 {
t.Errorf("esperado 2, obtido %d", resultado)
}
})
}
2. Execução Seletiva e Hierarquia de Subtests
Uma das maiores vantagens dos subtests é a capacidade de executar apenas um subconjunto específico de testes usando a flag -run com expressões regulares.
Executando subtests específicos:
# Executa apenas o subtest "soma"
go test -run "TestCalculadora/soma"
# Executa todos os subtests que contêm "soma"
go test -run "/soma"
# Executa subtests aninhados
go test -run "TestUsuario/Criar/valido"
Aninhamento de subtests:
func TestUsuario(t *testing.T) {
t.Run("Criar", func(t *testing.T) {
t.Run("valido", func(t *testing.T) {
// testa criação com dados válidos
})
t.Run("invalido", func(t *testing.T) {
t.Run("email_vazio", func(t *testing.T) {
// testa criação com email vazio
})
t.Run("senha_curta", func(t *testing.T) {
// testa criação com senha curta
})
})
})
}
Controle de fluxo: Use t.Fatal quando não fizer sentido continuar o teste (ex: falha ao criar fixture), e t.Error para reportar falhas que não impedem a execução de outras verificações.
t.Run("processar", func(t *testing.T) {
dados, err := carregarFixtures()
if err != nil {
t.Fatalf("falha ao carregar fixtures: %v", err)
}
resultado := processar(dados)
if resultado.Erro != nil {
t.Errorf("processamento falhou: %v", resultado.Erro)
}
})
3. Testes Tabelados com Subtests
A combinação de table-driven tests com subtests cria uma abordagem elegante e escalável para testar múltiplos cenários.
func TestCalculadora(t *testing.T) {
tests := []struct {
nome string
a, b int
operacao string
esperado int
}{
{"soma_positivos", 2, 3, "soma", 5},
{"soma_negativos", -2, -3, "soma", -5},
{"subtracao_positivos", 5, 3, "subtracao", 2},
{"multiplicacao", 4, 3, "multiplicacao", 12},
}
for _, tt := range tests {
t.Run(tt.nome, func(t *testing.T) {
var resultado int
switch tt.operacao {
case "soma":
resultado = tt.a + tt.b
case "subtracao":
resultado = tt.a - tt.b
case "multiplicacao":
resultado = tt.a * tt.b
}
if resultado != tt.esperado {
t.Errorf("%s: esperado %d, obtido %d", tt.nome, tt.esperado, resultado)
}
})
}
}
Paralelismo em subtests: Use t.Parallel() dentro de subtests para executar casos independentes concorrentemente.
func TestProcessamentoParalelo(t *testing.T) {
casos := []string{"caso1", "caso2", "caso3"}
for _, caso := range casos {
caso := caso // captura para closure
t.Run(caso, func(t *testing.T) {
t.Parallel()
resultado := processar(caso)
if resultado != "ok" {
t.Errorf("falha ao processar %s", caso)
}
})
}
}
4. Helpers de Teste: Funções Auxiliares
Helpers são funções auxiliares que encapsulam lógica repetitiva de testes. A função t.Helper() marca a função como auxiliar, removendo-a do stack trace quando ocorre uma falha, apontando diretamente para a linha do teste que chamou o helper.
Definição básica:
func verificarErro(t *testing.T, err error, mensagem string) {
t.Helper()
if err != nil {
t.Errorf("%s: erro inesperado: %v", mensagem, err)
}
}
func TestUsuario(t *testing.T) {
err := criarUsuario("joao@email.com")
verificarErro(t, err, "criar usuário")
}
Helper para criar fixtures:
func criarUsuarioFixture(t *testing.T) *Usuario {
t.Helper()
usuario := &Usuario{
Nome: "João Silva",
Email: "joao@email.com",
Senha: "senha123",
}
err := usuario.Salvar()
if err != nil {
t.Fatalf("falha ao criar fixture: %v", err)
}
return usuario
}
func TestBuscarUsuario(t *testing.T) {
usuario := criarUsuarioFixture(t)
encontrado, err := buscarPorEmail(usuario.Email)
if err != nil {
t.Fatalf("erro ao buscar usuário: %v", err)
}
if encontrado.Nome != usuario.Nome {
t.Errorf("nome esperado %s, obtido %s", usuario.Nome, encontrado.Nome)
}
}
5. Helpers com Setup e Teardown
Para cenários que exigem limpeza (como banco de dados temporário ou arquivos), crie helpers que retornam funções de cleanup.
func setupBancoTeste(t *testing.T) (*sql.DB, func()) {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("falha ao conectar: %v", err)
}
// Executa migrations
_, err = db.Exec(`
CREATE TABLE usuarios (
id INTEGER PRIMARY KEY,
nome TEXT,
email TEXT UNIQUE
)
`)
if err != nil {
t.Fatalf("falha ao criar tabela: %v", err)
}
// Retorna função de cleanup
cleanup := func() {
db.Close()
}
return db, cleanup
}
func TestUsuarioBanco(t *testing.T) {
db, cleanup := setupBancoTeste(t)
defer cleanup()
usuario := &Usuario{Nome: "Maria", Email: "maria@email.com"}
err := salvarUsuario(db, usuario)
if err != nil {
t.Fatalf("erro ao salvar: %v", err)
}
encontrado, err := buscarUsuario(db, usuario.Email)
if err != nil || encontrado == nil {
t.Error("usuário não encontrado após salvar")
}
}
Helper com setup condicional:
func setupComCleanup(t *testing.T, nome string) func() {
t.Helper()
recurso, err := criarRecurso(nome)
if err != nil {
t.Fatalf("falha ao criar recurso %s: %v", nome, err)
}
return func() {
if err := recurso.Fechar(); err != nil {
t.Logf("aviso: erro ao limpar recurso %s: %v", nome, err)
}
}
}
6. Tratamento de Erros e Asserções Customizadas
Crie helpers de asserção para melhorar a legibilidade e consistência dos testes.
func assertEqual(t *testing.T, esperado, obtido interface{}, msg ...interface{}) {
t.Helper()
if esperado != obtido {
mensagem := fmt.Sprintf("esperado %v, obtido %v", esperado, obtido)
if len(msg) > 0 {
mensagem = fmt.Sprintf("%s: %s", fmt.Sprint(msg...), mensagem)
}
t.Error(mensagem)
}
}
func assertNoError(t *testing.T, err error, msg ...interface{}) {
t.Helper()
if err != nil {
mensagem := fmt.Sprintf("erro inesperado: %v", err)
if len(msg) > 0 {
mensagem = fmt.Sprintf("%s: %s", fmt.Sprint(msg...), mensagem)
}
t.Fatal(mensagem)
}
}
func TestComAssercoes(t *testing.T) {
resultado := somar(2, 3)
assertEqual(t, 5, resultado, "soma básica")
err := validarEmail("invalido")
assertNoError(t, err) // espera-se erro, então isso falhará
}
Mini-biblioteca de asserções:
type Assercoes struct {
t *testing.T
}
func NewAssercoes(t *testing.T) *Assercoes {
return &Assercoes{t: t}
}
func (a *Assercoes) Igual(esperado, obtido interface{}, msg ...string) {
a.t.Helper()
if esperado != obtido {
mensagem := fmt.Sprintf("esperado %v, obtido %v", esperado, obtido)
if len(msg) > 0 {
mensagem = fmt.Sprintf("%s: %s", msg[0], mensagem)
}
a.t.Error(mensagem)
}
}
func (a *Assercoes) SemErro(err error, msg ...string) {
a.t.Helper()
if err != nil {
mensagem := fmt.Sprintf("erro inesperado: %v", err)
if len(msg) > 0 {
mensagem = fmt.Sprintf("%s: %s", msg[0], mensagem)
}
a.t.Fatal(mensagem)
}
}
7. Boas Práticas e Armadilhas Comuns
Isolamento real entre subtests:
// ERRADO: variável compartilhada
var contador int
t.Run("primeiro", func(t *testing.T) {
contador++
})
t.Run("segundo", func(t *testing.T) {
contador++ // depende do primeiro
})
// CORRETO: cada subtest tem seu escopo
t.Run("primeiro", func(t *testing.T) {
contador := 0
contador++
})
t.Run("segundo", func(t *testing.T) {
contador := 0
contador++
})
Cuidados com closures em loops:
// ERRADO: captura de variável em closure
valores := []int{1, 2, 3}
for _, v := range valores {
t.Run(fmt.Sprintf("valor_%d", v), func(t *testing.T) {
// v sempre será o último valor (3)
fmt.Println(v)
})
}
// CORRETO: cria nova variável a cada iteração
for _, v := range valores {
v := v // importante!
t.Run(fmt.Sprintf("valor_%d", v), func(t *testing.T) {
fmt.Println(v)
})
}
Quando usar subtests vs testes separados:
- Subtests: Quando testa variações de uma mesma funcionalidade (diferentes entradas, casos de borda)
- Testes separados: Quando testa funcionalidades completamente diferentes ou quando o setup é muito diferente entre cenários
Trade-offs:
- Subtests oferecem melhor organização e execução seletiva
- Testes separados têm nomes mais descritivos na saída do go test -v
- Subtests aninhados podem tornar a hierarquia complexa demais
Referências
- Testing package documentation — Documentação oficial do pacote testing sobre subtests e sub-benchmarks
- Go blog: Using Subtests and Sub-benchmarks — Artigo oficial do Go blog explicando subtests com exemplos práticos
- How to write table-driven tests in Go — Guia prático de table-driven tests combinados com subtests por Mat Ryer
- Go testing with subtests — Tutorial da DigitalOcean sobre subtests com exemplos de execução seletiva
- Effective Go: Testing — Seção de testes no Effective Go, incluindo boas práticas para helpers e asserções