Testes em Go: testing, tabelas de casos e benchmarks

Testes em Go: testing, tabelas de casos e benchmarks

Testes em Go: arquivos _test.go, Error e Fatal, tabelas de casos com t.Run, helpers, TestMain, t.TempDir e t.Cleanup, benchmarks com b.Loop do Go 1.24, exemplos com Output e Unordered output, cobertura, testes paralelos, o defer que roda cedo demais e o detector de corridas.
Golang

• • 16 min de leitura

Em muitas linguagens, testar código exige escolher entre uma dúzia de frameworks, aprender suas APIs e configurar um ambiente separado. Em Go, testes são parte da linguagem. O pacote testing está na biblioteca padrão, o comando go test está no toolchain e a convenção de nomes é simples e universalmente adotada.

Não existe uma razão legítima para escrever código Go sem testes. O custo de entrada é mínimo e os benefícios — confiança nas mudanças, documentação executável, detecção precoce de regressões — são imensos.

Anatomia de um teste

Todo arquivo de teste em Go segue três regras:

  • O nome do arquivo termina com _test.go
  • O pacote é o mesmo do código testado (ou com sufixo _test para testes externos)
  • Cada função de teste começa com Test e recebe *testing.T como único parâmetro
// arquivo: calculadora/calculadora.go
package calculadora

import "fmt"

func Somar(a, b float64) float64 {
    return a + b
}

func Dividir(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("divisão por zero")
    }
    return a / b, nil
}
// arquivo: calculadora/calculadora_test.go
package calculadora

import (
    "testing"
)

func TestSomar(t *testing.T) {
    resultado := Somar(2, 3)
    esperado := 5.0

    if resultado != esperado {
        t.Errorf("Somar(2, 3) = %.1f; esperado %.1f", resultado, esperado)
    }
}

Para executar:

go test ./...               # todos os pacotes
go test ./calculadora/      # pacote específico
go test -v ./calculadora/   # verbose — mostra cada teste
go test -run TestSomar      # executa apenas testes que correspondem ao padrão

Os métodos de *testing.T

O tipo *testing.T oferece um conjunto de métodos para reportar falhas e controlar a execução:

func TestMetodos(t *testing.T) {
    // Reporta falha mas continua o teste
    t.Error("algo deu errado")
    t.Errorf("valor obtido: %d, esperado: %d", 1, 2)

    // Reporta falha e para o teste imediatamente
    t.Fatal("falha irrecuperável")
    t.Fatalf("não foi possível abrir arquivo: %v", err)

    // Marca o teste como ignorado
    t.Skip("pulando — requer banco de dados")
    t.Skipf("pulando na versão %s", runtime.Version())

    // Log — visível apenas com -v ou quando o teste falha
    t.Log("contexto adicional")
    t.Logf("estado atual: %+v", estrutura)
}

A diferença entre Error e Fatal é importante: Error registra a falha e continua executando o restante do teste — útil para verificar múltiplas condições. Fatal para o teste imediatamente — útil quando as verificações seguintes dependem do estado atual e não fazem sentido se ele for inválido.

Tabela de casos: o padrão mais importante

O padrão de table-driven tests é o idioma central de testes em Go. Em vez de escrever uma função de teste para cada caso, define-se uma tabela com todos os casos e itera-se sobre ela:

package calculadora

import (
    "math"
    "testing"
)

func TestDividir(t *testing.T) {
    casos := []struct {
        nome      string
        a, b      float64
        esperado  float64
        erroEsperado bool
    }{
        {"divisão simples",     10,  2,  5.0,  false},
        {"divisão com decimal", 7,   2,  3.5,  false},
        {"dividendo zero",      0,   5,  0.0,  false},
        {"divisão por zero",    10,  0,  0.0,  true},
        {"negativos",          -10, -2,  5.0,  false},
        {"resultado negativo",  10, -2, -5.0,  false},
    }

    for _, tc := range casos {
        tc := tc // captura de variável de laço — necessário antes do Go 1.22
        t.Run(tc.nome, func(t *testing.T) {
            resultado, err := Dividir(tc.a, tc.b)

            if tc.erroEsperado {
                if err == nil {
                    t.Error("esperava erro, mas não recebeu nenhum")
                }
                return
            }

            if err != nil {
                t.Fatalf("erro inesperado: %v", err)
            }

            if math.Abs(resultado-tc.esperado) > 1e-9 {
                t.Errorf("Dividir(%.1f, %.1f) = %.4f; esperado %.4f",
                    tc.a, tc.b, resultado, tc.esperado)
            }
        })
    }
}

t.Run cria subtestes nomeados. Cada caso aparece separadamente na saída com -v, pode ser executado individualmente com -run TestDividir/divisão_simples e falhas em um caso não impedem os demais de executar.

Helpers de teste

Funções auxiliares reutilizadas por múltiplos testes são comuns. O método t.Helper() marca a função como helper — quando ela reporta uma falha, a linha apontada no erro é a do chamador, não a do helper:

package calculadora

import "testing"

func assertEqual(t *testing.T, obtido, esperado float64) {
    t.Helper() // essencial para mensagens de erro úteis
    if obtido != esperado {
        t.Errorf("obtido %.4f; esperado %.4f", obtido, esperado)
    }
}

func assertErro(t *testing.T, err error) {
    t.Helper()
    if err == nil {
        t.Error("esperava um erro, mas não recebeu nenhum")
    }
}

func assertSemErro(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("erro inesperado: %v", err)
    }
}

func TestSomarComHelpers(t *testing.T) {
    assertEqual(t, Somar(2, 3), 5.0)
    assertEqual(t, Somar(-1, 1), 0.0)
    assertEqual(t, Somar(0, 0), 0.0)
}

Setup e Teardown com TestMain

Quando testes precisam de setup global — como inicializar um banco de dados de teste ou criar arquivos temporários — TestMain permite executar código antes e depois de todos os testes do pacote:

package calculadora

import (
    "fmt"
    "os"
    "testing"
)

func TestMain(m *testing.M) {
    // Setup — executado antes de todos os testes
    fmt.Println("Inicializando ambiente de teste...")

    // m.Run() executa todos os testes e retorna o código de saída
    codigo := m.Run()

    // Teardown — executado após todos os testes
    fmt.Println("Limpando ambiente de teste...")

    os.Exit(codigo)
}

Para setup e teardown por teste individual, use funções auxiliares chamadas no início e defer para limpeza:

func configurarTeste(t *testing.T) func() {
    t.Helper()
    // setup
    dir, err := os.MkdirTemp("", "teste-*")
    if err != nil {
        t.Fatal(err)
    }

    // retorna função de teardown
    return func() {
        os.RemoveAll(dir)
    }
}

func TestComArquivos(t *testing.T) {
    teardown := configurarTeste(t)
    defer teardown()

    // ... teste usando os arquivos temporários
}

Para o caso mais comum, o de diretório temporário, o próprio pacote já resolve: dir := t.TempDir() (Go 1.15) cria o diretório e o apaga sozinho no fim do teste. Para outras limpezas, t.Cleanup(func() { ... }) (Go 1.14) registra a função no próprio teste, o que dispensa devolver o teardown e lembrar do defer.

Benchmarks: medindo performance

Funções de benchmark seguem a mesma convenção dos testes, com Benchmark no lugar de Test e *testing.B como parâmetro:

package calculadora

import "testing"

func BenchmarkSomar(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Somar(42.0, 58.0)
    }
}

func BenchmarkDividir(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Dividir(100.0, 3.0)
    }
}

b.N é ajustado automaticamente pelo framework até que o resultado seja estatisticamente confiável. Desde o Go 1.24, a forma recomendada é for b.Loop() { ... }, que dispensa o b.N, exclui da medição o setup feito antes do laço e impede o compilador de eliminar a chamada medida. Com uma função trivial como Somar, a diferença aparece: no Go 1.27, o laço com b.N mediu 0,24 ns/op — menos de um ciclo de processador, sinal de que a chamada foi otimizada até sumir —, e o com b.Loop, 0,79 ns/op. Para executar benchmarks:

go test -bench=.                    # todos os benchmarks
go test -bench=BenchmarkSomar       # benchmark específico
go test -bench=. -benchmem          # inclui alocações de memória
go test -bench=. -count=5           # executa 5 vezes para estabilidade
go test -bench=. -benchtime=3s      # executa por 3 segundos em vez do padrão

Saída típica:

BenchmarkSomar-8      1000000000     0.3125 ns/op
BenchmarkDividir-8    756123456      1.584  ns/op     0 B/op    0 allocs/op

Os campos significam: nome do benchmark, número de iterações, tempo por operação, bytes alocados por operação e número de alocações por operação.

Benchmarks com reset e pause

Quando o setup do benchmark é custoso e não deve ser medido, b.ResetTimer zera o timer após o setup:

func BenchmarkProcessarSliceGrande(b *testing.B) {
    // Setup custoso — não deve ser medido
    dados := make([]int, 100000)
    for i := range dados {
        dados[i] = i
    }

    b.ResetTimer() // começa a medir aqui

    for i := 0; i < b.N; i++ {
        processarSlice(dados)
    }
}

Para pausar e retomar o timer durante o loop:

func BenchmarkComIO(b *testing.B) {
    for i := 0; i < b.N; i++ {
        b.StopTimer()
        // preparar dados para esta iteração
        dados := gerarDados()
        b.StartTimer()

        processar(dados)
    }
}

Testes de exemplo: documentação executável

Funções de exemplo começam com Example e servem simultaneamente como documentação e como testes. O comentário // Output: define a saída esperada — se não corresponder, o teste falha:

func ExampleSomar() {
    resultado := Somar(3, 4)
    fmt.Println(resultado)
    // Output:
    // 7
}

func ExampleDividir() {
    resultado, err := Dividir(10, 4)
    if err != nil {
        fmt.Println("Erro:", err)
        return
    }
    fmt.Printf("%.2f\n", resultado)
    // Output:
    // 2.50
}

Esses exemplos aparecem automaticamente na documentação gerada pelo go doc e pelo pkg.go.dev, tornando-os a forma mais eficiente de documentar comportamento — a documentação nunca fica desatualizada porque é verificada pelo go test.

Cobertura de testes

Go possui suporte nativo para medir cobertura de código:

# Executar testes com cobertura
go test -cover ./...

# Gerar arquivo de cobertura detalhado
go test -coverprofile=cobertura.out ./...

# Visualizar no terminal
go tool cover -func=cobertura.out

# Visualizar em HTML — abre no navegador
go tool cover -html=cobertura.out

A visualização HTML colore cada linha: verde para linhas executadas durante os testes, vermelho para linhas não cobertas. É a forma mais rápida de identificar cenários não testados.

Testes paralelos

Testes que não compartilham estado podem ser executados em paralelo para reduzir o tempo total:

func TestOperacoesParalelas(t *testing.T) {
    casos := []struct {
        nome string
        a, b float64
        esp  float64
    }{
        {"caso1", 1, 2, 3},
        {"caso2", 4, 5, 9},
        {"caso3", 10, 20, 30},
    }

    for _, tc := range casos {
        tc := tc // redundante em módulos com go 1.22 ou mais
        t.Run(tc.nome, func(t *testing.T) {
            t.Parallel() // este subteste pode rodar em paralelo com outros
            resultado := Somar(tc.a, tc.b)
            if resultado != tc.esp {
                t.Errorf("obtido %.1f, esperado %.1f", resultado, tc.esp)
            }
        })
    }
}
go test -parallel 4 ./...  # até 4 testes em paralelo

O detector de race conditions

Go possui um detector de race conditions integrado que instrumenta o binário para detectar acessos concorrentes não sincronizados:

go test -race ./...
go run -race main.go
go build -race -o app-com-race ./

O detector tem custo: segundo a documentação do Go, o programa costuma ficar de 2 a 20 vezes mais lento e usar de 5 a 10 vezes mais memória. E ele só encontra disputas que acontecem durante a execução — um caminho de código que os testes não exercitam continua invisível para ele. Use em CI/CD e em desenvolvimento, mas não em binários de produção.

Testar em Go não pede framework: um arquivo _test.go, funções com o prefixo certo e o go test dão conta de testes, subtestes, benchmarks, exemplos e cobertura. A tabela de casos com t.Run é o padrão que mais aparece, e os detalhes que costumam custar horas estão em volta dela — o t.Cleanup no lugar do defer quando há subtestes paralelos, o t.Fatal que só vale na goroutine do teste, o benchmark bom demais para ser verdade. O go vet completo e o -race no CI fecham boa parte dessas brechas.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um teste abre um banco de dados de teste, registra defer db.Close() e, num laço, cria subtestes com t.Run, cada um chamando t.Parallel(). Os subtestes falham de forma intermitente com "banco fechado". Por que o defer do teste pai roda antes dos subtestes terminarem, e qual é a correção?

Ver resposta

✓ Resposta: Um subteste que chama t.Parallel() é pausado e só volta a rodar depois que a função do teste pai retorna, e é nesse retorno que os defer do pai executam. A ordem real é: o pai registra os subtestes, chega ao fim, roda db.Close() e só então os subtestes paralelos seguem, já com o banco fechado. Num teste com um recurso marcado como "aberto" e um defer que o marca como "fechado", a saída mostrou defer do pai rodou antes das linhas dos subtestes, e todos viram fechado. A correção é t.Cleanup(func() { db.Close() }) no lugar do defer: as funções de limpeza só rodam depois que o teste e todos os seus subtestes terminam. No mesmo experimento, com t.Cleanup, o subteste viu aberto. A regra vale para qualquer recurso compartilhado com subtestes paralelos, como servidores de teste, diretórios e conexões.

Exercício 2

O benchmark BenchmarkSomar do artigo reporta cerca de 0,24 ns por operação, e alguém conclui que Somar é praticamente gratuita. O que esse número realmente indica, e como medir de forma confiável?

Ver resposta

✓ Resposta: Indica que, muito provavelmente, a chamada nem está sendo feita. Um processador moderno leva algo em torno de 0,2 a 0,3 ns por ciclo, e um resultado abaixo de um ciclo por operação é o sinal clássico de que o compilador fez o inlining de Somar, percebeu que o resultado não é usado e eliminou a conta; o que se mede é o laço vazio. No Go 1.27, o mesmo benchmark escrito com for i := 0; i < b.N; i++ mediu 0,237 ns/op, e reescrito com for b.Loop(), disponível desde o Go 1.24, mediu 0,79 ns/op, porque o b.Loop impede o compilador de eliminar as chamadas dentro do laço. Em versões anteriores, a técnica era guardar o resultado numa variável de pacote, para que ele não pudesse ser descartado. Com funções de verdade, que alocam, fazem I/O ou percorrem dados, o efeito é menor, mas o hábito de desconfiar de números bons demais continua valendo. Outra regra é rodar com -count e comparar com o benchstat, porque uma execução isolada de benchmark varia bastante.

Exercício 3

Para testar código concorrente, alguém escreve dentro de um teste uma goroutine que chama t.Fatal quando encontra um resultado errado. O go test passa sem reclamar. O que está errado nesse teste, e qual ferramenta aponta o problema?

Ver resposta

✓ Resposta: t.Fatal e t.FailNow encerram o teste chamando runtime.Goexit, que para apenas a goroutine atual. Chamados de outra goroutine, eles param só essa goroutine, e o teste propriamente dito pode seguir, esperar para sempre por um sinal que não vai chegar ou terminar antes de a falha ser registrada. A documentação do pacote proíbe esse uso. Quem aponta é o go vet completo, com a mensagem call to (*testing.T).Fatal from a non-test goroutine. O go test roda só um subconjunto das verificações do vet, e essa não está nele: num teste medido, o go test passou e o go vet acusou a linha exata. Por isso o go vet ./... merece um passo próprio no CI. A correção é a goroutine devolver o resultado por um canal ou por uma variável protegida, e o teste principal decidir com t.Fatal; chamar t.Error na goroutine é permitido, mas não interrompe nada.

Exercício 4

Um exemplo de documentação percorre um map e imprime as chaves, com um bloco // Output: listando a, b e c. O teste passa às vezes e falha às vezes. Qual é a causa, e qual diretiva resolve sem mudar o código do exemplo? E por que o go test recusou compilar quando o exemplo se chamava ExampleMapa?

Ver resposta

✓ Resposta: A causa é a ordem de iteração do map, que o runtime sorteia a cada laço. // Output: compara a saída linha a linha, na ordem, e só passa quando o sorteio coincide com a ordem escrita. A diretiva // Unordered output: compara as linhas como um conjunto, em qualquer ordem, e um exemplo medido com três chaves passou com ela de forma estável. Quando a ordem faz parte do que o exemplo quer documentar, a correção certa é ordenar as chaves antes de imprimir. Quanto ao nome, o sufixo depois de Example não é livre: ExampleSomar documenta a função Somar, ExampleConta_Depositar documenta um método, e um nome que não corresponde a nenhum identificador do pacote é rejeitado pelo vet que o go test executa, com ExampleMapa refers to unknown identifier: Mapa. Para exemplos que não pertencem a um identificador, a forma é o sufixo com sublinhado e minúscula, como Example_mapa, que aparece como exemplo do pacote na documentação.

Exercício 5

Com a tabela de casos do artigo, um desenvolvedor quer rodar só o caso "divisão simples" e digita go test -run 'TestDividir/divisão simples'. Nenhum teste roda. Qual é o nome real do subteste, e o que dois outros hábitos, o -count=1 e o -race, acrescentam a esse fluxo de depuração?

Ver resposta

✓ Resposta: O t.Run troca os espaços do nome por sublinhados, então o subteste se chama TestDividir/divisão_simples; num teste com os casos "a b" e "c", a saída com -v mostrou TestPaiDefer/a_b. O argumento de -run é uma expressão regular aplicada a cada nível separado por /, então -run 'TestDividir/divisão_simples' seleciona só esse caso, e -run 'TestDividir/negativ' pega todos os que contêm o trecho. O -count=1 desliga o cache de resultados do go test: sem ele, um pacote cujo código não mudou mostra (cached) e não roda de novo, o que engana quando o teste depende de algo externo, como arquivos, variáveis de ambiente ou rede. O -race liga o detector de condições de corrida, e combinado com -count alto, como -count=50, aumenta a chance de fazer aparecer uma falha intermitente, que é justamente o tipo de defeito mais difícil de reproduzir.

Comentários

Mais em Golang

Logging estruturado com slog e Zerolog
Logging estruturado com slog e Zerolog

Logging estruturado em Go com log/slog e Zerolog: handlers de texto e JSON…

Arrays e Slices: a espinha dorsal das coleções em Go
Arrays e Slices: a espinha dorsal das coleções em Go

Arrays e slices em Go: tamanho no tipo, len e cap, fatiamento que compartilha…

Instalação, configuração do ambiente e o comando `go`
Instalação, configuração do ambiente e o comando `go`

Instalação do Go no Linux, macOS e Windows, o PATH que também precisa incluir…