Testes de tabela respondem a uma pergunta: “o que eu imaginei que pudesse quebrar, quebra?”. Fuzz testing responde a uma pergunta bem mais incômoda: “o que eu n imaginei que pudesse quebrar, quebra?”. É essa a diferença que torna o fuzz nativo do Go (estável desde o 1.18) uma das ferramentas de qualidade mais subutilizadas da linguagem: em vez de você alimentar o teste com entradas pré-listadas, o runtime gera milhares de entradas aleatórias e mutacionais e tenta derrubar seu código.
Neste guia você vai escrever fuzz targets idiomáticos, gerar e versionar um corpus, reproduzir e corrigir um bug encontrado pelo fuzzer e integrar tudo ao CI — sem nenhuma biblioteca externa, usando apenas go test -fuzz. Ele complementa o guia de table-driven tests, o guia de Testcontainers, o guia de erros com errors.Join e o guia de pprof em produção, cobrindo a faixa de qualidade entre “cobre os caminhos felizes” e “encontra inputs patológicos que ninguém previu”.
Por que fuzz testing importa
Testes unitários clássicos são necessários, mas têm uma falácia embutida: só testam o que o autor do teste considerou. Para um parser de JSON, um validador de CPF, um decodificador de query string ou um serializador de protobuf, a superfície de entrada é praticamente infinita. O autor escreve dez casos “típicos” e talvez dois “de borda”, mas bugs reais — integer overflow, UTF-8 malformado, strings gigantes, caracteres nulos no meio do input, recursão exagerada — vivem exatamente fora desses dez casos.
Fuzz testing inverte o controle. Em vez do autor escolher a entrada, o motor de fuzz gera entradas usando mutação guiada por cobertura: parte de seeds conhecidos, mede quais caminhos do código cada input exercita e muta os inputs que chegam a código novo. O objetivo não é “aleatoriedade pura”, é cobertura crescente. Quando um input causa panic, nil dereference, index out of range, timeout ou qualquer asserção falha, o fuzzer grava o input ofensivo em testdata/fuzz/<NomeDoTeste> e o teste falha de forma reproduzível.
Os bugs que fuzz encontra costumam ser exatamente os que viram CVEs em parsers e protocolos: uma string de 2GB que esgota memória, um campo negativo que vira índice inválido, um byte 0x00 que confunde parsers C-style. Em Go, onde panics em produção matam a goroutine (e às vezes o processo inteiro se não houver recover), encontrar essas entradas antes do deploy é barato e valioso.
Quando usar (e quando não usar)
Fuzz brilha em funções que recebem input não confiável e produzem output ou estado. Os alvos típicos são:
- Parsers: JSON, YAML, TOML, query string, headers HTTP, mensagens de protocolo binário.
- Validadores: CPF/CNPJ, e-mail, URL, telefone, schema.
- Serializadores/deserializadores: protobuf, MessagePack, CSV, formatos próprios.
- Funções cripto-adjacentes: normalização de string, comparação de hashes, decodificação Base64/Bearer token.
- Filtros e transformações: sanitização de HTML, escape de SQL, normalização de path.
Fuzz não substitui testes de unidade. Ele é complementar:
- Teste de tabela: afirma comportamento esperado para entradas conhecidas.
- Fuzz: afirma invariante para entradas arbitrárias (ex.: “nunca panic”, “se parseou, o output re-serializa igual”, “tempo limitado”).
Se sua funções não recebe input externo, ou se a invariante não é fácil de expressar, fuzz agrega pouco. Não fuzze um cálculo puro sem ramificação: não há o que descobrir.
Anatomia de um fuzz target
Um fuzz target é uma função FuzzXxx(*testing.F), irmã da TestXxx(*testing.T). Dentro dela você chama f.Add(...) para registrar seeds e f.Fuzz(func(t *testing.T, ...)) para declarar a invariante. O motor cuida do resto.
package parser
import (
"errors"
"strconv"
"testing"
)
var ErrNotPositive = errors.New("must be positive")
// ParsePositiveInt converte string em int e exige valor > 0.
func ParsePositiveInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if n <= 0 {
return 0, ErrNotPositive
}
return n, nil
}
func FuzzParsePositiveInt(f *testing.F) {
// Seeds: o ponto de partida do motor de mutação.
f.Add("1")
f.Add("42")
f.Add("999999")
f.Add("-5")
f.Add("0")
f.Add("")
f.Fuzz(func(t *testing.T, s string) {
n, err := ParsePositiveInt(s)
// Invariante 1: nunca panic.
// (implícita: se a função panicar, o teste falha sozinho)
// Invariante 2: contrato consistente.
if err == nil && n <= 0 {
t.Errorf("aceitou valor não positivo: %q -> %d", s, n)
}
if err != nil && n != 0 {
t.Errorf("retornou erro e valor não-zero: %q -> %d", s, n)
}
// Invariante 3: round-trip (se parseou, re-serializa igual).
if err == nil {
if strconv.Itoa(n) != s {
// Pode ser legítimo para "007" -> 7; aqui flaggamos para revisar.
t.Logf("round-trip divergiu: %q -> %d", s, n)
}
}
})
}
Três pontos-chave:
f.Addregistra seeds. O motor muta a partir deles. Seeds bons cobrem caminhos felizes e bordas óbvias (vazio, negativo, zero, muito grande).- A função dentro de
f.Fuzzé o coração do target. É nela que você expressa a invariante. Não tente adivinhar qual input virá — qualquer input pode chegar. - Asserções são sobre invariantes, não valores exatos. Você não sabe o input, então não pode prever o output. Mas pode prever propriedades: nunca panic, nunca retorna estado inválido, sempre re-serializa igual.
O tipo de cada argumento de f.Fuzz e f.Add deve ser um dos tipos suportados: string, []byte, int, int8/16/32/64, uint*, float32/64, bool, rune. Para structs complexas, passe string ou []byte e desserialize dentro do target — é assim que você fuzz de parsers de protocolo.
Rodando o fuzzer
O fluxo tem dois modos: go test normal (consome corpus existente, não gera nada novo) e go test -fuzz=... (gera inputs indefinidamente até achar um bug ou você cancelar).
Modo corpus (rápido, entra no CI)
go test ./...
go test -run=^FuzzParsePositiveInt$ ./...
Sem -fuzz, o Go roda os seeds e qualquer entrada que já exista em testdata/fuzz/FuzzParsePositiveInt/. É rápido, determinístico e deveria ser parte do go test ./... normal. Se um bug for encontrado e salvo no corpus, ele falha para sempre até você corrigir — exatamente como um teste de regressão.
Modo geração (longo, rodado localmente ou em job dedicado)
go test -fuzz=^FuzzParsePositiveInt$ -fuzztime=2m ./...
Com -fuzz, o motor gera inputs novos durante o tempo definido. Quando encontra um panic ou falha de asserção, ele:
- Para o processo com saída não-zero.
- Escreve o input ofensivo em
testdata/fuzz/FuzzParsePositiveInt/<hash>. - Imprime o caminho do arquivo e a stack trace.
A partir desse momento, o bug vira um caso de regressão: rodar go test simples já o reproduz.
Limites práticos
-fuzzaceita um único target por invocação (use-fuzz=Parsepara filtrar por nome).- A geração é CPU-bound e paralela por padrão (
-parallel). Em CI dedique um job separado, não misture com ogo test ./...rápido. - Defina
-fuzztimesempre. Sem limite, o processo roda até você matar (útil em job noturno, perigoso em laptop). - Use
-fuzzminimizetimepara controlar quanto tempo o motor gasta tentando reduzir o input ofensivo ao menor caso reproduzível — útil quando o bug apareceu num input de 8KB e você quer um de 12 bytes.
Um bug real encontrado por fuzz
Vamos rodar o target acima por 30 segundos e ver o que acontece. Em uma implementação ingênua, é comum esquecer que strconv.Atoi aceita underscores ("1_000"), sinais ("+5") e whitespace ao redor (" 7 "):
$ go test -fuzz=^FuzzParsePositiveInt$ -fuzztime=30s ./...
--- FAIL: FuzzParsePositiveInt
parse_test.go:42: aceitou valor não positivo: "+0" -> 0
parse_test.go:47: round-trip divergiu: "007" -> 7
--- FAIL: FuzzParsePositiveInt
parse_test.go:42: aceitou valor não positivo: " 10 " -> 10
O motor achou que "+0" e " 10 " passaram pela validação de > 0 em caminhos que o autor do teste original não imaginou. O Go salvou os inputs:
$ cat testdata/fuzz/FuzzParsePositiveInt/a1b2c3
go test fuzz v1
[]byte("0")
$ cat testdata/fuzz/FuzzParsePositiveInt/d4e5f6
go test fuzz v1
[]byte("+0")
A correção pode ser normalizar (strings.TrimSpace), rejeitar explicitamente (strings.ContainsAny(s, "+_ ")) ou mudar de strconv.Atoi para um parser estrito. O ponto é: o bug agora é visível e reproduzível, e o corpus garante que ele nunca mais volta silenciosamente.
Versionando o corpus
O diretório testdata/fuzz/ deve entrar no git. Cada entrada é um caso de regressão permanente. Boas práticas:
- Commite cada novo crash assim que confirmar a correção — ele vira teste de regressão.
- Se o corpus crescer demais (milhares de arquivos), separe em um job de fuzz “exploratório” que roda fora do CI rápido e só promove crashes reais para
testdata/fuzz/. - Não commite lixo: arquivos grandes (>10KB) ou duplicados poluem o repo. O
-fuzzminimizetimeajuda a reduzir o tamanho.
Invariantes úteis por categoria
Expressar a invariante certa é a arte do fuzz. Aqui vão padrões reutilizáveis:
Parsers (round-trip)
f.Fuzz(func(t *testing.T, raw []byte) {
parsed, err := MyParser(raw)
if err != nil {
return // erro é caminho válido
}
// Invariante: re-serializar o parsed produz bytes equivalentes.
out := MySerializer(parsed)
parsed2, err := MyParser(out)
if err != nil {
t.Fatalf("re-parse falhou após round-trip: %v", err)
}
if !Equal(parsed, parsed2) {
t.Errorf("round-trip não estável: %+v != %+v", parsed, parsed2)
}
})
Validadores (idempotência e simetria)
f.Fuzz(func(t *testing.T, s string) {
ok1 := IsValid(s)
normalized := Normalize(s)
ok2 := IsValid(normalized)
// Invariante: normalizar algo válido produz algo válido.
if ok1 && !ok2 {
t.Errorf("Normalize quebrou validade: %q -> %q", s, normalized)
}
})
Funções que prometem não-panic
f.Fuzz(func(t *testing.T, s string) {
// A simples execução sem panic já é a invariante.
// Se você quiser checar mais, adicione asserções.
_ = SafeFunc(s)
})
Serialização com tamanho limitado (anti-DoS)
f.Fuzz(func(t *testing.T, s string) {
start := time.Now()
_ = HeavyFunc(s)
// Invariante: nunca demora mais que 100ms, qualquer input.
if time.Since(start) > 100*time.Millisecond {
t.Errorf("HeavyFunc lento para input de %d bytes", len(s))
}
})
Esse último padrão é poderoso para detectar ReDoS (regular expression denial of service) e algoritmos quadráticos que viram DoS em produção. Combine com o guia de pprof para confirmar o gargalo.
Fuzz no CI
A integração com CI tem duas camadas:
- Por commit/PR: rode
go test ./...normalmente. Isso consome o corpus versionado e falha se um crash histórico regrediu. É barato. - Job periódico dedicado: rode
go test -fuzz=... -fuzztime=10mpara cada target crítico, de hora em hora ou uma vez por dia. Descobrir bugs de fuzz não deve bloquear PR; deve abrir issue.
Exemplo de job dedicado em GitHub Actions:
name: fuzz
on:
schedule:
- cron: "0 3 * * *"
jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
- run: go test -fuzz=^FuzzParse$ -fuzztime=10m ./internal/parser/
- run: go test -fuzz=^FuzzValidate$ -fuzztime=10m ./internal/validator/
Se o job achar um crash, ele falha, abre issue e o input fica no log. Em seguida um humano reproduz localmente, adiciona ao corpus e corrige.
Armadilhas comuns
1. Asserções fracas
// Ruim: não afirma nada. O motor vai rodar milhões de inputs sem aprender nada.
f.Fuzz(func(t *testing.T, s string) {
_ = Parse(s)
})
Sem asserção sobre o output, o fuzzer só detecta panics. Panics são ótimos, mas a maior parte dos bugs lógicos não panicam — produzem resultados errados silenciosamente. Adicione invariantes (round-trip, simetria, contrato).
2. Seeds pobres
Se todos os seeds forem "abc", "123", "hello", o motor demora muito para descobrir caminhos exóticos. Inclua bordas reais: vazio, Unicode, null bytes, tamanhos absurdos, caracteres de controle, dígitos negativos, notação científica, JSON aninhado fundido.
3. Side-effects fora do target
// Ruim: muta estado global; dificulta reprodução determinística.
var cache = map[string]int{}
f.Fuzz(func(t *testing.T, s string) {
cache[s]++ // cada execução interfere na próxima
})
Fuzz targets devem ser puros ou isolados. Use variáveis locais, evite mutar globals, resete estado em t.Cleanup. Caso contrário, reproduzir o crash vira impossível.
4. Tempo ilimitado
Sem -fuzztime, o processo roda para sempre. Em laptop, isso drena bateria; em CI, trava o job. Sempre defina o limite.
5. Misturar fuzz com benchmark
Fuzz não mede performance — mede corretude sob entradas arbitrárias. Para medir performance, use testing.B combinado com pprof e PGO. Para encontrar ReDoS via tempo, o padrão “assert duração máxima” acima resolve, mas não use fuzz como benchmark sistemático.
Avançado: fuzzing de protocolos binários
Para fuzz de gRPC/protobuf, mensagens binárias ou formatos próprios, passe []byte e desserialize dentro do target:
func FuzzProtobufMessage(f *testing.F) {
// Seeds em bytes válidos.
f.Add([]byte{0x0a, 0x05, 'h', 'e', 'l', 'l', 'o'})
f.Add([]byte{})
f.Fuzz(func(t *testing.T, raw []byte) {
var msg pb.MyMessage
if err := proto.Unmarshal(raw, &msg); err != nil {
return // bytes inválidos são esperados
}
// Invariante: re-marshalar produz bytes equivalentes (não idênticos,
// mas semanticamente iguais).
out, err := proto.Marshal(&msg)
if err != nil {
t.Fatalf("marshal falhou após unmarshal: %v", err)
}
var msg2 pb.MyMessage
if err := proto.Unmarshal(out, &msg2); err != nil {
t.Fatalf("segundo unmarshal falhou: %v", err)
}
if !proto.Equal(&msg, &msg2) {
t.Errorf("round-trip protobuf instável")
}
})
}
Isso pega bugs em bindings gerados, campos desconhecidos mal tratados, loops de recursão em mensagens aninhadas e problemas de tamanho — exatamente o tipo de coisa que vira CVE em APIs gRPC.
Conclusão
Fuzz testing nativo do Go é barato de adotar e desproporcionalmente valioso. Em meia hora você transforma um parser “testado” em um parser validado contra milhares de inputs que você nunca imaginou. Os passos para começar agora:
- Escolha uma função que recebe input externo (parser, validador, deserializador).
- Escreva um
FuzzXxxcom 5–10 seeds cobrindo caminhos felizes e bordas. - Expresse uma invariante clara (round-trip, simetria, não-panic, duração máxima).
- Rode
go test -fuzz=^FuzzXxx$ -fuzztime=2mlocalmente. - Commite qualquer crash encontrado como caso de regressão.
- Adicione
go test ./...ao CI (consome o corpus) e um job periódico com-fuzztimemaior.
A regra prática: se sua função recebe bytes que um usuário ou sistema externo controla, ela deveria ter um fuzz target. Bugs de parser são caros em produção e quase gratuitos de encontrar com fuzz — desde que você escreva o target.
Leituras relacionadas
- Table-driven tests em Go — a base sobre a qual o fuzz se apoia.
- Testcontainers em Go — quando o fuzz precisa de um serviço real para reproduzir.
- errors.Join para múltiplos erros — propagar erros de parsing sem perder contexto.
- pprof em produção — confirmar gargalos que o fuzz apontou como lentos.
- PGO: Profile-Guided Optimization — otimizar os hot paths que o fuzz ajudou a mapear.
- gRPC e Protobuf em Go — alvo clássico de fuzz de protocolo binários.
- Go para backend — contexto onde parsers e validadores vivem.
- Testes em Go — visão geral da suíte de testes da linguagem.