Testes orientados a tabela paralelos em Go

Acelere os testes em Go com execução paralela

Conteúdo da página

Testes orientados por tabela são a abordagem idiomática no Go para testar múltiplos cenários de forma eficiente. Quando combinados com a execução paralela usando t.Parallel(), você pode reduzir drasticamente o tempo de execução do conjunto de testes, especialmente para operações limitadas por E/S.

No entanto, a execução paralela de testes introduz desafios únicos em torno de condições de corrida e isolamento de testes que exigem atenção cuidadosa.

Testes orientados por tabela paralelos no Go - condições de corrida no Go

Entendendo a execução paralela de testes

O pacote de testes do Go fornece suporte integrado à execução paralela de testes através do método t.Parallel(). Quando um teste chama t.Parallel(), ele sinaliza ao executor de testes que esse teste pode ser executado simultaneamente com outros testes paralelos. Isso é particularmente poderoso quando combinado com testes orientados por tabela, onde você tem muitos casos de teste independentes que podem ser executados simultaneamente.

A paralelismo padrão é controlado por GOMAXPROCS, que normalmente é igual ao número de núcleos de CPU no seu computador. Você pode ajustar isso com a bandeira -parallel: go test -parallel 4 limita os testes simultâneos a 4, independentemente da contagem de CPU. Isso é útil para controlar o uso de recursos ou quando os testes têm requisitos específicos de concorrência.

Para desenvolvedores novos no teste em Go, entender os fundamentos é crucial. Nosso guia sobre melhores práticas para testes unitários no Go aborda testes orientados por tabela, subtestes e os fundamentos do pacote de testes que formam a base para a execução paralela.

Padrão Básico de Testes Orientados por Tabela em Paralelo

Aqui está o padrão correto para testes orientados por tabela em paralelo:

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"adição", 2, 3, "+", 5, false},
        {"subtração", 5, 3, "-", 2, false},
        {"multiplicação", 4, 3, "*", 12, false},
        {"divisão", 10, 2, "/", 5, false},
        {"divisão por zero", 10, 0, "/", 0, true},
    }

    for _, tt := range tests {
        tt := tt // Capturar variável de loop
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Habilitar execução paralela
            result, err := Calculate(tt.a, tt.b, tt.op)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            
            if result != tt.expected {
                t.Errorf("Calculate(%d, %d, %q) = %d; want %d", 
                    tt.a, tt.b, tt.op, result, tt.expected)
            }
        })
    }
}

A linha crítica é tt := tt antes de t.Run(). Isso captura o valor atual da variável de loop, garantindo que cada subteste paralelo opere com sua própria cópia dos dados do caso de teste.

O Problema da Captura da Variável de Loop

Este é um dos erros mais comuns ao usar t.Parallel() com testes orientados por tabela. No Go, a variável de loop tt é compartilhada entre todas as iterações. Quando os subtestes são executados em paralelo, eles podem todos se referir à mesma variável tt, que é sobrescrita conforme o loop continua. Isso leva a condições de corrida e falhas de teste imprevisíveis.

Incorreto (condição de corrida):

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // Todos os subtestes podem ver o mesmo valor de tt!
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

Correto (variável capturada):

for _, tt := range tests {
    tt := tt // Capturar a variável de loop
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // Cada subteste tem sua própria cópia de tt
        result := Calculate(tt.a, tt.b, tt.op)
    })
}

A atribuição tt := tt cria uma nova variável com escopo para a iteração do loop, garantindo que cada goroutine tenha sua própria cópia dos dados do caso de teste.

Garantindo a Independência dos Testes

Para que os testes em paralelo funcionem corretamente, cada teste deve ser completamente independente. Eles não devem:

  • Compartilhar estado global ou variáveis
  • Modificar recursos compartilhados sem sincronização
  • Depender da ordem de execução
  • Acessar os mesmos arquivos, bancos de dados ou recursos de rede sem coordenação

Exemplo de testes paralelos independentes:

func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name    string
        email   string
        wantErr bool
    }{
        {"e-mail válido", "user@example.com", false},
        {"formato inválido", "not-an-email", true},
        {"domínio ausente", "user@", true},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            err := ValidateEmail(tt.email)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", 
                    tt.email, err, tt.wantErr)
            }
        })
    }
}

Cada caso de teste opera com seus próprios dados de entrada, sem estado compartilhado, tornando seguro para execução paralela.

Detectando Condições de Corrida

O Go fornece um poderoso detector de corridas para capturar corridas de dados em testes paralelos. Sempre execute seus testes paralelos com a bandeira -race durante o desenvolvimento:

go test -race ./...

O detector de corridas informará qualquer acesso concorrente à memória compartilhada sem sincronização adequada. Isso é essencial para capturar bugs sutis que podem aparecer apenas sob condições de timing específicas.

Exemplo de condição de corrida:

var counter int // Estado compartilhado - PERIGOSO!

func TestIncrement(t *testing.T) {
    tests := []struct {
        name string
        want int
    }{
        {"teste1", 1},
        {"teste2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            counter++ // CONDIÇÃO DE CORRIDA!
            if counter != tt.want {
                t.Errorf("counter = %d, want %d", counter, tt.want)
            }
        })
    }
}

Executando isso com -race detectará a modificação concorrente de counter. A correção é tornar cada teste independente usando variáveis locais em vez de estado compartilhado.

Benefícios de Desempenho

A execução paralela pode reduzir significativamente o tempo de execução do conjunto de testes. A aceleração depende de:

  • Número de núcleos de CPU: Mais núcleos permitem que mais testes sejam executados simultaneamente
  • Características dos testes: Testes limitados por E/S beneficiam-se mais do que testes limitados por CPU
  • Contagem de testes: Conjuntos de testes maiores veem maior economia de tempo absoluta

Medindo o desempenho:

# Execução sequencial
go test -parallel 1 ./...

# Execução paralela (padrão)
go test ./...

# Paralelismo personalizado
go test -parallel 8 ./...

Para conjuntos de testes com muitas operações de E/S (consultas ao banco de dados, requisições HTTP, operações de arquivo), você pode frequentemente obter uma aceleração de 2 a 4 vezes em sistemas modernos com múltiplos núcleos. Testes limitados por CPU podem ver menos benefícios devido à competição por recursos de CPU.

Controle de Paralelismo

Você tem várias opções para controlar a execução paralela de testes:

1. Limitar o número máximo de testes paralelos:

go test -parallel 4 ./...

2. Definir GOMAXPROCS:

GOMAXPROCS=2 go test ./...

3. Execução paralela seletiva:

Marque apenas testes específicos com t.Parallel(). Testes sem essa chamada são executados sequencialmente, o que é útil quando alguns testes devem ser executados em ordem ou compartilham recursos.

4. Execução paralela condicional:

func TestExpensive(t *testing.T) {
    if testing.Short() {
        t.Skip("pulando teste caro no modo curto")
    }
    t.Parallel()
    // Lógica do teste caro
}

Padrões Comuns e Boas Práticas

Padrão 1: Configuração Antes da Execução Paralela

Se você precisar de uma configuração compartilhada por todos os casos de teste, faça-a antes do loop:

func TestWithSetup(t *testing.T) {
    // Código de configuração é executado uma vez, antes da execução paralela
    db := setupTestDatabase(t)
    defer db.Close()

    tests := []struct {
        name string
        id   int
    }{
        {"usuário1", 1},
        {"usuário2", 2},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // Cada teste usa db de forma independente
            user := db.GetUser(tt.id)
            // Lógica de teste...
        })
    }
}

Padrão 2: Configuração por Teste

Para testes que precisam de configuração isolada, faça-a dentro de cada subteste:

func TestWithPerTestSetup(t *testing.T) {
    tests := []struct {
        name string
        data string
    }{
        {"teste1", "dados1"},
        {"teste2", "dados2"},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            // Cada teste obtém sua própria configuração
            tempFile := createTempFile(t, tt.data)
            defer os.Remove(tempFile)
            // Lógica de teste...
        })
    }
}

Padrão 3: Misto de Execução Sequencial e Paralela

Você pode misturar testes sequenciais e paralelos no mesmo arquivo:

func TestSequential(t *testing.T) {
    // Nenhum t.Parallel() - executa sequencialmente
    // Bom para testes que devem ser executados em ordem
}

func TestParallel(t *testing.T) {
    tests := []struct{ name string }{{"teste1"}, {"teste2"}}
    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // Esses executam em paralelo
        })
    }
}

Quando NÃO Usar a Execução Paralela

A execução paralela nem sempre é apropriada. Evite-a quando:

  1. Testes compartilham estado: Variáveis globais, singleton ou recursos compartilhados
  2. Testes modificam arquivos compartilhados: Arquivos temporários, bancos de dados de teste ou arquivos de configuração
  3. Testes dependem da ordem de execução: Alguns testes devem ser executados antes de outros
  4. Testes já são rápidos: A sobrecarga da paralelização pode exceder os benefícios
  5. Restrições de recursos: Testes consomem muito memória ou CPU quando paralelizados

Para testes relacionados a bancos de dados, considere usar rollbacks de transações ou bancos de dados de teste separados por teste. Nosso guia sobre padrões de banco de dados multi-tenant no Go aborda estratégias de isolamento que funcionam bem com testes paralelos.

Avançado: Testando Código Concorrente

Quando testa código concorrente (não apenas executando testes em paralelo), você precisa de técnicas adicionais:

func TestConcurrentOperation(t *testing.T) {
    tests := []struct {
        name      string
        goroutines int
    }{
        {"2 goroutines", 2},
        {"10 goroutines", 10},
        {"100 goroutines", 100},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            var wg sync.WaitGroup
            results := make(chan int, tt.goroutines)
            
            for i := 0; i < tt.goroutines; i++ {
                wg.Add(1)
                go func() {
                    defer wg.Done()
                    results <- performOperation()
                }()
            }
            
            wg.Wait()
            close(results)
            
            // Verificar resultados
            count := 0
            for range results {
                count++
            }
            if count != tt.goroutines {
                t.Errorf("esperado %d resultados, obteve %d", tt.goroutines, count)
            }
        })
    }
}

Sempre execute tais testes com -race para detectar corridas de dados no código sob teste.

Integração com CI/CD

Testes paralelos se integram de forma suave com pipelines de CI/CD. A maioria dos sistemas de CI fornece múltiplos núcleos de CPU, tornando a execução paralela altamente benéfica:

# Exemplo GitHub Actions
- name: Executar testes
  run: |
    go test -race -coverprofile=coverage.out -parallel 4 ./...
    go tool cover -html=coverage.out -o coverage.html    

A bandeira -race é especialmente importante em CI para capturar bugs de concorrência que podem não aparecer no desenvolvimento local.

Depuração de Falhas em Testes Paralelos

Quando testes paralelos falham intermitentemente, a depuração pode ser desafiadora:

  1. Execute com -race: Identifique corridas de dados
  2. Reduza a paralelismo: go test -parallel 1 para ver se as falhas desaparecem
  3. Execute testes específicos: go test -run TestName para isolar o problema
  4. Adicione logs: Use t.Log() para rastrear a ordem de execução
  5. Verifique por estado compartilhado: Procure por variáveis globais, singletons ou recursos compartilhados

Se os testes passam sequencialmente, mas falham em paralelo, você provavelmente tem uma condição de corrida ou problema de estado compartilhado.

Exemplo Prático: Testando Manipuladores HTTP

Aqui está um exemplo prático de testes de manipuladores HTTP em paralelo:

func TestHTTPHandlers(t *testing.T) {
    router := setupRouter()
    
    tests := []struct {
        name       string
        method     string
        path       string
        statusCode int
    }{
        {"GET usuários", "GET", "/users", 200},
        {"GET usuário por ID", "GET", "/users/1", 200},
        {"POST usuário", "POST", "/users", 201},
        {"DELETE usuário", "DELETE", "/users/1", 204},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            req := httptest.NewRequest(tt.method, tt.path, nil)
            w := httptest.NewRecorder()
            
            router.ServeHTTP(w, req)
            
            if w.Code != tt.statusCode {
                t.Errorf("esperado status %d, obteve %d", tt.statusCode, w.Code)
            }
        })
    }
}

Cada teste usa httptest.NewRecorder(), que cria um gravador de resposta isolado, tornando esses testes seguros para execução paralela.

Conclusão

A execução paralela de testes orientados por tabela é uma técnica poderosa para reduzir o tempo de execução do conjunto de testes no Go. A chave para o sucesso é compreender a necessidade de captura de variáveis de loop, garantir a independência dos testes e usar o detector de corridas para capturar problemas de concorrência cedo.

Lembre-se:

  • Sempre capture variáveis de loop: tt := tt antes de t.Parallel()
  • Garanta que os testes sejam independentes, sem estado compartilhado
  • Execute os testes com -race durante o desenvolvimento
  • Controle a paralelismo com a bandeira -parallel quando necessário
  • Evite a execução paralela para testes que compartilham recursos

Ao seguir essas práticas, você pode aproveitar com segurança a execução paralela para acelerar seus conjuntos de testes, mantendo a confiabilidade. Para mais padrões de teste no Go, veja nosso guia abrangente sobre testes unitários no Go, que aborda testes orientados por tabela, mocks e outras técnicas essenciais de teste.

Ao construir aplicações Go maiores, essas práticas de teste se aplicam a diferentes domínios. Por exemplo, ao construir aplicações CLI com Cobra & Viper, você usará padrões semelhantes de testes paralelos para testar manipuladores de comandos e análise de configuração.

Recursos Externos