DEV Community

Cover image for Table-Driven Tests e Paralelismo
Jean Grijp
Jean Grijp

Posted on

Table-Driven Tests e Paralelismo

Em Go, a convenção para testar uma função com vários casos é montar uma tabela de entradas e resultados esperados e percorrê-la com um loop. É o padrão chamado de table-driven tests. Junto com subtestes (t.Run) e execução paralela (t.Parallel), ele cobre cenários felizes, casos extremos e caminhos de erro em uma função só, e a suíte roda em uma fração do tempo sequencial.

Neste texto você vai ver a anatomia desse padrão, o que o t.Parallel() faz por dentro e a armadilha de captura da variável de iteração, que durante mais de uma década fez testes paralelos passarem sem testar os casos que deviam.

O problema dos testes repetitivos

Digamos que você implementou uma função de cálculo de desconto. Escrevendo um teste para cada cenário, o arquivo fica assim:

// 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Cada caso novo duplica o corpo da função, exige um nome longo e repete a asserção. Se a assinatura de CalcularDesconto mudar, você altera dezenas de funções espalhadas pelo arquivo.

A anatomia de um table-driven test

O jeito idiomático separa os dados do teste da lógica de asserção. Uma tabela, que é um slice anônimo de structs, guarda as entradas e os resultados esperados, e um loop executa tudo com o mesmo código:

Diagrama animado: a tabela de casos alimenta o runner, que dispara um t.Run por caso e coleta os resultados no relatório

A estrutura tem quatro partes: a tabela, o loop sobre ela, um subteste por caso e a asserção.

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)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Por que uma struct anônima ([]struct{...})? Ela só existe dentro da função de teste, então não polui o pacote com tipos que ninguém mais usa. Adicionar um cenário vira acrescentar uma linha de valores. E os campos nomeados (name: "...", input: ...) deixam claro o que cada valor significa, sem depender da posição.

Subtestes com t.Run()

O t.Run(name, func(t *testing.T)) chegou no Go 1.7 e mudou a forma como o Go reporta resultados. Cada volta do loop vira um subteste independente:

  • O relatório é hierárquico. O runner mostra o teste pai, uma barra e o nome do subteste: TestOperacao/número_positivo_par.
  • A falha é isolada. Se um subteste falhar com t.Fatalf, só ele é interrompido. O loop continua e os outros casos da tabela são avaliados.
  • Dá para rodar um cenário só pelo terminal, com uma expressão regular na flag -run:
# Executa apenas o subteste de número ímpar
go test -v -run TestOperacao/número_ímpar
Enter fullscreen mode Exit fullscreen mode

t.Errorf ou t.Fatalf

Use t.Fatalf quando continuar não faz sentido. Se a função devia devolver um objeto e devolveu nil, seguir adiante causaria um panic de ponteiro nulo ao acessar user.Email. O t.Fatalf aborta o subteste atual na hora.

Use t.Errorf quando a execução terminou normalmente, mas o valor final difere do esperado. O teste continua e confere os outros campos da mesma resposta, então você vê todos os erros de uma vez no log.

Execução paralela com t.Parallel()

Por padrão, os testes de um pacote Go rodam em sequência. Numa suíte com centenas de testes, ou com parsing de arquivos pesados, criptografia e I/O, isso deixa núcleos da CPU parados.

Chamar t.Parallel() dentro de um subteste instrui o runner a executá-lo em paralelo com os outros testes paralelos:

Diagrama animado: três subtestes de 100ms rodando um após o outro no modo sequencial e ao mesmo tempo no modo paralelo

O ciclo de vida de um teste paralelo

Quando o Go encontra um t.Parallel(), o subteste passa por três fases:

  1. RUN: o subteste começa a executar na goroutine do teste pai.
  2. PAUSE: ao chegar no t.Parallel(), ele pausa na hora, entra na lista de testes paralelos pendentes e libera o loop principal para seguir.
  3. CONT: quando a função pai termina de registrar todos os subtestes e chega ao fim do seu escopo, o runtime acorda os subtestes pausados em goroutines simultâneas, distribuídas entre os núcleos disponíveis.

Dá para ver isso na saída de go test -v:

=== 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
Enter fullscreen mode Exit fullscreen mode

Controlando o paralelismo

O número máximo de testes paralelos simultâneos é controlado pela flag -parallel. O padrão é o valor de GOMAXPROCS, que normalmente é o número de núcleos da CPU:

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

A armadilha da variável de loop

Durante mais de dez anos, combinar for ... range com t.Parallel() foi fonte de um dos bugs mais traiçoeiros do Go:

Diagrama animado: no Go 1.21 todas as goroutines de teste leem o mesmo tc e testam só o caso C; no Go 1.22+ ou com tc := tc, cada subteste testa o seu caso

O código perigoso é este:

// 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(...)
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Antes do Go 1.22, a variável tc era alocada uma única vez para o loop inteiro. A cada iteração o Go só sobrescrevia o mesmo endereço de memória com o próximo item.

O t.Parallel() pausa a goroutine do subteste, e o loop principal continua correndo até o fim da tabela. Quando os subtestes acordam, todos enxergam o valor da última iteração. O teste reporta sucesso em todos os casos, mas na prática rodou o último caso várias vezes e ignorou os primeiros.

As duas soluções

Em Go 1.21 e anteriores, a saída é redeclarar a variável dentro do loop, o que cria uma cópia nova a cada volta:

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)
        ...
    })
}
Enter fullscreen mode Exit fullscreen mode

No Go 1.22 e superiores, a semântica da linguagem mudou: cada iteração do for cria uma nova instância da variável por padrão. A linha tc := tc deixou de ser necessária, e essa classe de falha desapareceu.

Dica: se você mantém uma biblioteca de código aberto que ainda dá suporte ao Go 1.20 ou 1.21, deixe a linha tc := tc explícita. Assim a suíte continua correta em qualquer versão do compilador.

Um exemplo completo

Vamos montar uma suíte para um validador de transações financeiras, com erros sentinela e t.Cleanup().

O código de negócio, em 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
}
Enter fullscreen mode Exit fullscreen mode

A suíte, em transacao_test.go. O loop abaixo assume Go 1.22 ou superior; em versões anteriores, acrescente tc := tc no início do corpo do for.

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)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Checklist antes de dar a suíte por concluída

  1. Compare erros com errors.Is, nunca por texto. Um if err.Error() == "texto" quebra na primeira vez que alguém reescrever a mensagem. Valide contra o erro sentinela com errors.Is(err, ErrAlvo).
  2. Rode com o detector de corridas na integração contínua:
   go test -race ./...
Enter fullscreen mode Exit fullscreen mode

Se os subtestes paralelos acessarem uma variável global ou outro recurso compartilhado sem proteção, o detector aponta as linhas em conflito.

  1. Dê nomes descritivos aos subtestes. O campo name deve dizer o que o cenário cobre e por quê, por exemplo "idade negativa deve retornar erro de validação".
  2. Não compartilhe estado entre testes paralelos. Um teste com t.Parallel() não deve alterar variáveis globais, tabelas de banco sem transação isolada ou o diretório de trabalho.
  3. Chame t.Helper() na primeira linha de qualquer função auxiliar de asserção. Assim o Go reporta a linha do teste que falhou, e não a linha dentro do utilitário.

Beba Água <3

go #golang #testing #tabledriventests #testesunitarios #paralelismo #concorrencia #backend #braziliandevs

Top comments (0)