Pular para o conteúdo

Trilha de Go · parte 12 de 37

Table-driven tests e paralelismo em Go

Como estruturar cenários de teste em tabelas com subtestes paralelos e por que a captura de variáveis no loop exigia cuidado antes do Go 1.22.

Publicada em · 10 min de leitura

Tamanho da fonte:
Largura da coluna:

No ecossistema Go, existe uma filosofia explícita sobre como testar software: testes devem ser legíveis, fáceis de estender, livres de mágica e executados com velocidade máxima. Em vez de criar dezenas de funções de teste quase idênticas que mudam apenas um número ou uma string de entrada, a comunidade e a equipe de engenharia do Go padronizaram o padrão Table-Driven Tests (Testes Orientados por Tabelas).

Combinado com subtestes (t.Run) e execução paralela (t.Parallel), este padrão permite cobrir dezenas de casos extremos, cenários felizes e caminhos de erro em uma única função organizada, com tempo de execução reduzido a uma fração do tempo sequencial.

Esta aula disseca a anatomia completa desse padrão, o comportamento do agendador de testes do Go por dentro, a famosa armadilha de captura de variáveis de iteração e as melhores práticas adotadas em projetos de grande porte como Kubernetes e Docker.


O Mapa da Aula

ParteSeçãoO que você vai dominar
1. Fundamentos1 e 2O problema dos testes repetitivos e a anatomia da tabela de structs
2. Subtestes3 e 4Isolamento com t.Run(), filtragem no terminal e a diferença entre t.Errorf e t.Fatalf
3. Paralelismo5 e 6Como t.Parallel() funciona no runtime (o ciclo PAUSE $\to$ CONT) e aceleração de suítes
4. Armadilhas7 e 8A clássica armadilha da variável de iteração no Go < 1.22 vs a semântica do Go 1.22+
5. Práticas de Produção9 e 10Asserção de erros sentinela com errors.Is, limpeza com t.Cleanup() e verificação de concorrência com -race

Parte 1: Fundamentos e Anatomia

1. O problema antes dos Table-Driven Tests

Imagine que você implementou uma função de cálculo de desconto ou validação de CPF/CNPJ. Se você escrever testes no estilo tradicional de outras linguagens sem parametrização estruturada, o código fica inchado e repetitivo:

// ❌ Abordagem prolixa e difícil de manter
func TestCalcularDesconto_ClienteNovo(t *testing.T) {
    got := CalcularDesconto(100.0, "NOVO")
    if got != 0.0 {
        t.Errorf("esperava 0.0, recebeu %f", got)
    }
}

func TestCalcularDesconto_ClienteVIP(t *testing.T) {
    got := CalcularDesconto(100.0, "VIP")
    if got != 15.0 {
        t.Errorf("esperava 15.0, recebeu %f", got)
    }
}

func TestCalcularDesconto_ClientePremium(t *testing.T) {
    got := CalcularDesconto(100.0, "PREMIUM")
    if got != 20.0 {
        t.Errorf("esperava 20.0, recebeu %f", got)
    }
}

Cada novo caso exige duplicar o corpo da função, inventar um novo nome longo e repetir asserções manuais. Se a assinatura da função CalcularDesconto mudar no futuro, você precisará alterar dezenas de funções espalhadas pelo arquivo.

2. A anatomia de um Table-Driven Test

Em Go, a abordagem idiomática separa os dados dos testes da lógica de asserção. Criamos uma tabela composta por um slice anônimo de structs contendo os parâmetros de entrada e os resultados esperados:

anatomia table driven test
figura 1 · anatomia table driven test

A estrutura padrão de um Table-Driven Test é composta por quatro elementos essenciais:

func TestOperacao(t *testing.T) {
    // 1. Definição da tabela de casos
    tests := []struct {
        name    string  // Descrição legível do cenário
        input   int     // Parâmetro de entrada
        want    int     // Resultado esperado
        wantErr bool    // Indica se esperamos um erro neste caso
    }{
        {
            name:    "número positivo par",
            input:   4,
            want:    2,
            wantErr: false,
        },
        {
            name:    "número ímpar",
            input:   5,
            want:    0,
            wantErr: true,
        },
    }

    // 2. Iteração sobre a tabela
    for _, tc := range tests {
        // 3. Execução em subteste isolado
        t.Run(tc.name, func(t *testing.T) {
            got, err := Operacao(tc.input)

            // 4. Asserção padronizada
            if (err != nil) != tc.wantErr {
                t.Fatalf("Operacao(%d) erro = %v, esperava erro? %v", tc.input, err, tc.wantErr)
            }
            if got != tc.want {
                t.Errorf("Operacao(%d) = %d; esperado = %d", tc.input, got, tc.want)
            }
        })
    }
}

Por que usar uma struct anônima ([]struct{...})?

  1. Escopo restrito: A struct só existe dentro daquela função de teste. Não polui o pacote com tipos auxiliares que ninguém mais usará.
  2. Adição instantânea de cenários: Adicionar um novo caso de teste se resume a acrescentar uma nova linha de valores literais na struct.
  3. Nomes nos campos: A sintaxe com chaves nomeadas (name: "...", input: ...) deixa explícito o que cada valor significa, sem ambiguidade posicional.

Parte 2: Subtestes com t.Run()

3. O poder dos subtestes

Introduzido no Go 1.7, o método t.Run(name, func(t *testing.T)) transformou a forma como o Go reporta resultados. Cada iteração do loop torna-se um subteste independente:

  • Relatório hierárquico: O runner exibe o nome do teste pai seguido de barra e o nome do subteste: TestOperacao/número_positivo_par.
  • Isolamento de falha: Se o primeiro subteste falhar com t.Fatalf, apenas aquele subteste é interrompido. O loop continua e todos os outros casos de teste da tabela são avaliados.
  • Execução seletiva via CLI: Você pode rodar apenas um cenário específico da tabela pelo terminal usando uma expressão regular na flag -run:
    # Executa apenas o subteste de número ímpar
    go test -v -run TestOperacao/número_ímpar

4. t.Errorf vs t.Fatalf: a regra de ouro

  • Use t.Fatalf para pré-condições críticas: Se a função deveria retornar um objeto e retornou nil, ou se falhou em um passo preliminar onde continuar causaria um panic de ponteiro nulo (como acessar user.Email quando user == nil), use t.Fatalf. Ele aborta o subteste atual imediatamente.
  • Use t.Errorf para divergências de valores: Se a execução foi concluída normalmente mas o valor final difere do esperado, use t.Errorf. O teste continuará avaliando outros campos da mesma resposta, permitindo que você veja todos os erros de uma só vez no log.

Parte 3: Execução Paralela com t.Parallel()

5. Como o t.Parallel() opera sob o capô

Por padrão, os testes em Go rodam sequencialmente, um após o outro. Em suítes com centenas de testes ou em cenários que envolvem parsing de arquivos pesados, criptografia ou I/O, a execução sequencial desperdiça os múltiplos núcleos da CPU.

Ao invocar t.Parallel() dentro de um subteste, você instrui o test runner a executar aquela rotina em paralelo com outros testes paralelos:

execucao sequencial vs paralela
figura 2 · execucao sequencial vs paralela

O ciclo de vida interno de um teste paralelo

Quando o Go encontra uma chamada t.Parallel(), acontece um mecanismo sofisticado de 3 fases:

  1. RUN: O subteste inicia sua execução na goroutine do teste pai.
  2. PAUSE: Ao encontrar t.Parallel(), o subteste pausa imediatamente, registra seu ponteiro na lista de testes paralelos pendentes e libera o loop principal para continuar.
  3. CONT: Assim que a função de teste pai termina de registrar todos os subtestes e atinge o fim do seu escopo, o runtime do Go desperta todos os subtestes pausados em goroutines simultâneas, distribuindo-os entre os núcleos disponíveis do processador.

Podemos constatar esse comportamento inspecionando a saída verbosa do compilador:

=== RUN   TestDividir_TableDriven
=== PAUSE TestDividir_TableDriven
=== CONT  TestDividir_TableDriven
=== RUN   TestDividir_TableDriven/positivo
=== PAUSE TestDividir_TableDriven/positivo
=== RUN   TestDividir_TableDriven/negativo
=== PAUSE TestDividir_TableDriven/negativo
=== CONT  TestDividir_TableDriven/negativo
=== CONT  TestDividir_TableDriven/positivo
--- PASS: TestDividir_TableDriven/positivo (0.00s)
--- PASS: TestDividir_TableDriven/negativo (0.00s)
PASS

6. Controlando o paralelismo

O Go gerencia o número máximo de testes paralelos concorrentes através da flag -parallel, cujo valor padrão é igual a GOMAXPROCS (número de núcleos da CPU):

# Permite até 16 testes paralelos simultâneos
go test -parallel=16 ./...

Parte 4: A Armadilha da Variável de Loop

7. O que acontecia no Go < 1.22

Durante mais de uma década, a combinação de for ... range com t.Parallel() foi a fonte de um dos bugs mais traiçoeiros do Go:

armadilha variavel loop
figura 3 · armadilha variavel loop

Veja o código perigoso:

// ⚠️ CUIDADO: Comportamento bugado em Go 1.21 e anteriores
for _, tc := range tests {
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel() // O subteste pausa aqui!
        
        // Quando for retomado, qual é o valor de tc?
        got := Processar(tc.input) 
        if got != tc.want {
            t.Errorf(...)
        }
    })
}

Por que isso quebrava?

Nas versões do Go anteriores a 1.22, a variável tc era alocada uma única vez para todo o loop. A cada iteração, o Go apenas sobrescrevia a mesma posição de memória com o próximo item.

Como o t.Parallel() pausa a goroutine do subteste, o loop principal continuava correndo em alta velocidade até o final da tabela. Quando os subtestes finalmente acordavam para rodar, todos eles enxergavam o valor da última iteração! O teste reportava que passou em todos os casos, mas na verdade testou o último caso 5 vezes seguidas e ignorou os primeiros.

8. As duas soluções

Solução Histórica (Obrigatória em Go $\le$ 1.21):

Redeclarar a variável no escopo interno do loop para criar uma cópia física nova a cada ciclo:

for _, tc := range tests {
    tc := tc // Cria uma cópia local isolada na stack
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        // Agora tc aponta para a cópia segura
        got := Processar(tc.input)
        ...
    })
}

Solução Moderna (Go 1.22+):

No Go 1.22 (e versões superiores), a semântica da linguagem foi aprimorada oficialmente: cada iteração do loop for cria uma nova instância de variável por padrão. Portanto, em Go moderno, a redeclaração tc := tc tornou-se desnecessária, eliminando essa classe inteira de falhas.


Parte 5: Código Prático Completo de Produção

Vamos construir uma suíte profissional completa para um validador de transações financeiras com tratamento de erros sentinela e limpeza com t.Cleanup().

O Código de Negócio (transacao.go):

package financeiro

import (
	"errors"
	"fmt"
)

var (
	ErrValorInvalido       = errors.New("o valor da transação deve ser positivo")
	ErrContaInvalida       = errors.New("número de conta de origem ou destino inválido")
	ErrLimiteUltrapassado  = errors.New("valor excede o limite diário da conta")
)

type Transacao struct {
	ContaOrigem  string
	ContaDestino string
	Valor        float64
}

// ValidarTransacao aplica as regras de conformidade bancária.
func ValidarTransacao(t Transacao, limiteDiario float64) error {
	if t.Valor <= 0 {
		return ErrValorInvalido
	}
	if len(t.ContaOrigem) < 5 || len(t.ContaDestino) < 5 {
		return ErrContaInvalida
	}
	if t.ContaOrigem == t.ContaDestino {
		return fmt.Errorf("%w: conta de origem idêntica ao destino", ErrContaInvalida)
	}
	if t.Valor > limiteDiario {
		return ErrLimiteUltrapassado
	}
	return nil
}

A Suíte de Testes Table-Driven (transacao_test.go):

package financeiro_test

import (
	"errors"
	"testing"

	"meu-modulo/financeiro"
)

func TestValidarTransacao(t *testing.T) {
	t.Parallel() // A suíte pai roda em paralelo com outras do pacote

	// Tabela abrangente de cenários de teste
	tests := []struct {
		name         string
		transacao    financeiro.Transacao
		limiteDiario float64
		wantErr      bool
		expectedErr  error
	}{
		{
			name: "transação regular aprovada",
			transacao: financeiro.Transacao{
				ContaOrigem:  "12345-6",
				ContaDestino: "98765-4",
				Valor:        150.00,
			},
			limiteDiario: 1000.00,
			wantErr:      false,
			expectedErr:  nil,
		},
		{
			name: "transação com valor zero",
			transacao: financeiro.Transacao{
				ContaOrigem:  "12345-6",
				ContaDestino: "98765-4",
				Valor:        0.00,
			},
			limiteDiario: 1000.00,
			wantErr:      true,
			expectedErr:  financeiro.ErrValorInvalido,
		},
		{
			name: "transação com valor negativo",
			transacao: financeiro.Transacao{
				ContaOrigem:  "12345-6",
				ContaDestino: "98765-4",
				Valor:        -50.00,
			},
			limiteDiario: 1000.00,
			wantErr:      true,
			expectedErr:  financeiro.ErrValorInvalido,
		},
		{
			name: "conta de origem com menos de 5 dígitos",
			transacao: financeiro.Transacao{
				ContaOrigem:  "123",
				ContaDestino: "98765-4",
				Valor:        100.00,
			},
			limiteDiario: 1000.00,
			wantErr:      true,
			expectedErr:  financeiro.ErrContaInvalida,
		},
		{
			name: "transferência para a mesma conta",
			transacao: financeiro.Transacao{
				ContaOrigem:  "12345-6",
				ContaDestino: "12345-6",
				Valor:        100.00,
			},
			limiteDiario: 1000.00,
			wantErr:      true,
			expectedErr:  financeiro.ErrContaInvalida,
		},
		{
			name: "valor excede o limite diário",
			transacao: financeiro.Transacao{
				ContaOrigem:  "12345-6",
				ContaDestino: "98765-4",
				Valor:        1500.00,
			},
			limiteDiario: 1000.00,
			wantErr:      true,
			expectedErr:  financeiro.ErrLimiteUltrapassado,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel() // Cada subteste executa em paralelo

			// t.Cleanup executa garantidamente no final deste subteste
			t.Cleanup(func() {
				// Ideal para fechar arquivos, resetar variáveis ou limpar caches locais
			})

			err := financeiro.ValidarTransacao(tc.transacao, tc.limiteDiario)

			// 1. Verificação da ocorrência de erro
			if (err != nil) != tc.wantErr {
				t.Fatalf("ValidarTransacao() erro = %v, esperava erro? %v", err, tc.wantErr)
			}

			// 2. Verificação precisa do tipo/causa do erro com errors.Is
			if tc.expectedErr != nil && !errors.Is(err, tc.expectedErr) {
				t.Errorf("ValidarTransacao() erro retornado = %v, esperava conter = %v", err, tc.expectedErr)
			}
		})
	}
}

Parte 6: Checklist de Qualidade e Boas Práticas

Antes de considerar sua suíte de testes concluída, valide estes 5 itens:

  1. Uso de errors.Is em vez de comparar strings: Nunca faça if err.Error() == "texto". Mensagens mudam com frequência; valide sempre contra erros sentinela com errors.Is(err, ErrAlvo).
  2. Execução obrigatória com detecção de corridas: Sempre execute seus testes em ambiente de integração com a flag -race:
    go test -race ./...
    Se houver qualquer acesso concorrente inseguro entre seus subtestes paralelos a variáveis globais ou recursos compartilhados, o compilador apontará exatamente as linhas conflitantes.
  3. Nomes descritivos de subtestes: O campo name deve explicar o o quê e o porquê daquele cenário (ex: "idade negativa deve retornar erro de validação").
  4. Ausência de estado compartilhado em testes paralelos: Testes com t.Parallel() nunca devem modificar variáveis globais, tabelas estáticas de banco sem transações isoladas ou alterar diretórios de trabalho compartilhados sem isolamento.
  5. Uso de t.Helper() em funções auxiliares: Se você extrair uma função auxiliar de asserção para fora do teste, declare t.Helper() na primeira linha dela. Isso faz com que o Go reporte a linha exata do teste que falhou, e não a linha interna da função utilitária.

Exercício Prático Proposto

Para consolidar o conhecimento desta aula:

  1. Implemente uma função CalcularFrete(pesoKg float64, distanciaKm float64, tipoFrete string) (float64, error).
  2. Crie a suíte TestCalcularFrete utilizando o padrão Table-Driven Tests com pelo menos 6 cenários (pesos inválidos $\le 0$, distâncias negativas, tipos de frete desconhecidos como "AEREO", frete "ECONOMICO" e frete "EXPRESSO").
  3. Ative t.Parallel() tanto no teste pai quanto em cada subteste.
  4. Execute no terminal:
    go test -v -race -run TestCalcularFrete
    e confirme na saída que os subtestes pausaram e retomaram em concorrência.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?