← Voltar para o blog

Fuzz Testing em Go: Encontrando Bugs com testing.F

Aprenda fuzz testing em Go com testing.F: crie fuzz targets, gere corpus, descubra panics e bugs de parsing/segurança que testes de tabela não encontram. Go nativo, sem dependências.

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:

  1. f.Add registra seeds. O motor muta a partir deles. Seeds bons cobrem caminhos felizes e bordas óbvias (vazio, negativo, zero, muito grande).
  2. 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.
  3. 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:

  1. Para o processo com saída não-zero.
  2. Escreve o input ofensivo em testdata/fuzz/FuzzParsePositiveInt/<hash>.
  3. 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

  • -fuzz aceita um único target por invocação (use -fuzz=Parse para filtrar por nome).
  • A geração é CPU-bound e paralela por padrão (-parallel). Em CI dedique um job separado, não misture com o go test ./... rápido.
  • Defina -fuzztime sempre. Sem limite, o processo roda até você matar (útil em job noturno, perigoso em laptop).
  • Use -fuzzminimizetime para 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 -fuzzminimizetime ajuda 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:

  1. Por commit/PR: rode go test ./... normalmente. Isso consome o corpus versionado e falha se um crash histórico regrediu. É barato.
  2. Job periódico dedicado: rode go test -fuzz=... -fuzztime=10m para 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:

  1. Escolha uma função que recebe input externo (parser, validador, deserializador).
  2. Escreva um FuzzXxx com 5–10 seeds cobrindo caminhos felizes e bordas.
  3. Expresse uma invariante clara (round-trip, simetria, não-panic, duração máxima).
  4. Rode go test -fuzz=^FuzzXxx$ -fuzztime=2m localmente.
  5. Commite qualquer crash encontrado como caso de regressão.
  6. Adicione go test ./... ao CI (consome o corpus) e um job periódico com -fuzztime maior.

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