Go Admin

Boas Práticas de Fuzzing em Go: Testes Baseados em Propriedades com go test -fuzz para Times Ágeis Já leu

O que é Fuzzing e por que você deveria se importar Fuzzing é uma técnica de teste automatizado que alimenta seu programa com dados aleatórios ou semi-aleatórios para descobrir comportamentos inesperados, bugs de segurança e crashes. Diferente dos testes unitários tradicionais, onde você escreve casos de teste específicos, o fuzzing gera centenas de milhares de entradas em busca de falhas que você não previu. Isso é particularmente valioso para funções que processam dados não confiáveis: parsers, validadores, criptografia e qualquer coisa que trabalhe com entrada do usuário. A grande vantagem do fuzzing em Go é que ele está integrado nativamente ao desde a versão 1.18. Você não precisa instalar ferramentas externas complexas ou aprender uma sintaxe estranha. É simples, poderoso e funciona de forma eficiente graças ao mecanismo de cobertura de código que o Go mantém internamente. Quando você escreve um fuzz test e o Go o executa, ele não apenas testa valores aleatórios—aprende quais entradas causam comportamentos interessantes e

O que é Fuzzing e por que você deveria se importar

Fuzzing é uma técnica de teste automatizado que alimenta seu programa com dados aleatórios ou semi-aleatórios para descobrir comportamentos inesperados, bugs de segurança e crashes. Diferente dos testes unitários tradicionais, onde você escreve casos de teste específicos, o fuzzing gera centenas de milhares de entradas em busca de falhas que você não previu. Isso é particularmente valioso para funções que processam dados não confiáveis: parsers, validadores, criptografia e qualquer coisa que trabalhe com entrada do usuário.

A grande vantagem do fuzzing em Go é que ele está integrado nativamente ao go test desde a versão 1.18. Você não precisa instalar ferramentas externas complexas ou aprender uma sintaxe estranha. É simples, poderoso e funciona de forma eficiente graças ao mecanismo de cobertura de código que o Go mantém internamente. Quando você escreve um fuzz test e o Go o executa, ele não apenas testa valores aleatórios—aprende quais entradas causam comportamentos interessantes e reutiliza essas pistas para gerar novas entradas mais prováveis de encontrar bugs.

Conceitos Fundamentais: Testes Baseados em Propriedades

O que é um teste baseado em propriedades?

Um teste baseado em propriedades não verifica um resultado exato. Em vez disso, você define uma propriedade que deve ser verdadeira para toda entrada válida. Por exemplo: "se eu criptografar uma mensagem e depois descriptografá-la, devo obter a mensagem original", ou "a função de ordenação nunca deve retornar um array com menos elementos que o input". O fuzzer então tenta quebrar essas propriedades.

Essa abordagem é mais poderosa que testes convencionais porque você não precisa pensar em todos os casos extremos—o fuzzer faz isso por você. A máquina consegue explorar combinações de dados muito mais rápido do que qualquer humano conseguiria escrever manualmente.

Como funciona o fuzzing em Go

Quando você escreve uma função FuzzXxx (um fuzz target), o Go a executa de duas formas distintas. Primeiro, em modo seed, ele testa com dados que você forneceu manualmente no arquivo testdata/fuzz/. Depois, em modo coverage-guided, o fuzzer gera novos dados aleatórios baseado em feedback: se uma entrada atinge código não explorado, o fuzzer a salva e a usa como base para gerar variações.

Isso significa que o fuzzer é inteligente—ele não apenas joga números aleatórios. Ele mapeia o caminho de execução do seu código e tenta encontrar caminhos que ainda não foram tocados. Quando encontra algo que causa pânico ou falha de asserção, salva essa entrada como um "corpus" no seu repositório para garantir que o bug não reaparece depois.

Estrutura e Sintaxe de um Fuzz Test

Escrevendo seu primeiro fuzz test

Um fuzz test em Go segue um padrão simples. A função recebe *testing.F como parâmetro, não *testing.T como nos testes normais. Você usa f.Add() para adicionar seeds (valores iniciais) e f.Fuzz() para definir a lógica que será testada contra dados aleatórios.

package strings

import (
    "testing"
)

func FuzzReverseString(f *testing.F) {
    // Seeds: valores iniciais que o fuzzer deve sempre testar
    f.Add("hello")
    f.Add("")
    f.Add("🎉")

    // Fuzz: a função que será chamada com dados aleatórios
    f.Fuzz(func(t *testing.T, input string) {
        // Teste a propriedade aqui
        reversed := ReverseString(input)
        doubleReversed := ReverseString(reversed)

        // A propriedade: reverter duas vezes volta ao original
        if input != doubleReversed {
            t.Fatalf("Double reverse failed: %q != %q", input, doubleReversed)
        }
    })
}

func ReverseString(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

Execute com go test -fuzz=FuzzReverseString -fuzztime=10s. O Go alimentará sua função com strings aleatórias por 10 segundos, procurando qualquer entrada que viole a propriedade. Se encontrar uma, salva a entrada problemática em testdata/fuzz/FuzzReverseString/ para reprodução futura.

Tipos suportados pelo fuzzer

O Go suporta um conjunto limitado de tipos para gerar automaticamente: string, []byte, rune, byte, int, int8/16/32/64, uint, uint8/16/32/64, bool e float32/64. Se sua função precisa de tipos personalizados, você encadeia múltiplas seeds ou usa reflection manual. Na prática, a maioria dos casos reais usa apenas string e []byte.

func FuzzParseJSON(f *testing.F) {
    f.Add([]byte(`{"name": "John"}`))
    f.Add([]byte(`[]`))
    f.Add([]byte(``))

    f.Fuzz(func(t *testing.T, input []byte) {
        // O fuzzer vai gerar []byte aleatórios
        var result interface{}
        _ = json.Unmarshal(input, &result)
        // Se panicar, o fuzzer detecta
    })
}

Casos Reais: Encontrando Bugs com Fuzzing

Exemplo 1: Validador de Email

Vamos considerar uma função que valida emails. Ela é fácil de escrever errado porque email é complexo (RFC 5322 é um pesadelo). Fuzzing pode encontrar casos que quebram sua expressão regular:

package email

import (
    "regexp"
    "testing"
)

var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9.+_-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

func IsValidEmail(email string) bool {
    return emailRegex.MatchString(email)
}

func FuzzEmailValidator(f *testing.F) {
    f.Add("user@example.com")
    f.Add("invalid.email")
    f.Add("")
    f.Add("@")

    f.Fuzz(func(t *testing.T, input string) {
        result := IsValidEmail(input)

        // Propriedade: se começa com @, é inválido
        if input != "" && input[0] == '@' && result {
            t.Fatalf("Invalid email passed: %q", input)
        }

        // Propriedade: se não tem @, é inválido
        if !containsChar(input, '@') && result {
            t.Fatalf("Email without @ passed: %q", input)
        }
    })
}

func containsChar(s string, c byte) bool {
    for i := 0; i < len(s); i++ {
        if s[i] == c {
            return true
        }
    }
    return false
}

Quando você roda go test -fuzz=FuzzEmailValidator, o fuzzer pode encontrar entradas como "..@.." ou "a@b" que passam na regex mas violam propriedades lógicas reais.

Exemplo 2: Função de Codec (Encode/Decode)

Qualquer função que codifica e depois decodifica é perfeita para fuzzing. A propriedade é simples: "round-trip" deve restaurar os dados originais.

package codec

import (
    "encoding/base64"
    "testing"
)

func FuzzBase64RoundTrip(f *testing.F) {
    f.Add([]byte("hello"))
    f.Add([]byte(""))
    f.Add([]byte{0, 1, 2, 255})

    f.Fuzz(func(t *testing.T, input []byte) {
        // Encode
        encoded := base64.StdEncoding.EncodeToString(input)

        // Decode
        decoded, err := base64.StdEncoding.DecodeString(encoded)
        if err != nil {
            t.Fatalf("Decode failed after encode: %v", err)
        }

        // Propriedade: deve recuperar exatamente
        if len(input) != len(decoded) {
            t.Fatalf("Length mismatch: %d != %d", len(input), len(decoded))
        }

        for i := range input {
            if input[i] != decoded[i] {
                t.Fatalf("Byte mismatch at index %d", i)
            }
        }
    })
}

Este teste é trivial porque base64 é confiável, mas imagine aplicá-lo a um codec customizado—o fuzzer acharia qualquer falha de round-trip rapidamente.

Exemplo 3: Detecção de Panics

Fuzzing também é excelente para encontrar panics inesperados. Se sua função pânica com entrada válida, o fuzzer captura:

package parser

import (
    "strconv"
    "testing"
)

func ParseAndDouble(input string) (int, error) {
    num, err := strconv.Atoi(input)
    if err != nil {
        return 0, err
    }
    return num * 2, nil
}

func FuzzParseAndDouble(f *testing.F) {
    f.Add("42")
    f.Add("0")
    f.Add("-1")

    f.Fuzz(func(t *testing.T, input string) {
        // Se isso panicar com qualquer string, o fuzzer detecta
        result, err := ParseAndDouble(input)

        // Propriedade: se não houve erro, resultado deve ser par
        if err == nil && result%2 != 0 {
            t.Fatalf("Result not even: %d", result)
        }
    })
}

Se houvesse um bug que causasse panic (como divisão por zero escondida), o fuzzer o encontraria e salvaria a entrada problemática.

Boas Práticas e Otimizações

Mantendo fuzz tests rápidos

Seu fuzz test será executado bilhões de vezes em ambientes CI/CD, então eficiência é crítica. Evite operações caras dentro de f.Fuzz(): não faça chamadas HTTP, não acesse banco de dados, não escreva em disco. Se precisar, use testdata/ para fixtures, não para I/O dinâmico.

// ❌ Ruim: muito lento
func FuzzProcessUser(f *testing.F) {
    f.Fuzz(func(t *testing.T, input []byte) {
        user := parseUser(input)
        db.Save(user)  // Não faça isso!
    })
}

// ✅ Bom: rápido e determinístico
func FuzzProcessUser(f *testing.F) {
    f.Fuzz(func(t *testing.T, input []byte) {
        user := parseUser(input)
        // Apenas valida lógica, sem I/O
        if user.Age < 0 {
            t.Fatal("Negative age")
        }
    })
}

Organizando seeds com testdata

Crie uma estrutura de diretórios testdata/fuzz/FuzzName/ para seeds:

project/
├── main.go
├── main_test.go
└── testdata/
    └── fuzz/
        └── FuzzParseConfig/
            ├── seed1
            ├── seed2
            └── seed3

Cada arquivo contém um seed binário. Você pode criar manualmente ou deixar o fuzzer gerar e depois comitar interessantes:

go test -fuzz=FuzzParseConfig -fuzztime=1m
# Go vai salvar entradas interessantes em testdata automaticamente
git add testdata/
git commit -m "Add fuzz seeds"

Interpretando resultados

Quando o fuzzer encontra um crash:

--- FAIL: FuzzReverseString (0.01s)
    --- FAIL: FuzzReverseString (0.01s)
        fuzz.go:23: Double reverse failed: "hello" != "olleh"

    Failing input written to testdata/fuzz/FuzzReverseString/12e4a8c

Aquele arquivo 12e4a8c é binário. O Go pode reproduced o erro:

go test -run FuzzReverseString/12e4a8c

Ele testa especificamente aquela entrada. Muito útil para debugging.

Conclusão

Fuzzing em Go não é um recurso avançado ou opcional—é uma ferramenta fundamental que você deveria incorporar imediatamente. Primeiro, aprenda que um teste fuzzy define uma propriedade que deve ser sempre verdadeira, não um resultado específico. Segundo, entenda que o Go faz o trabalho pesado: você escreve a lógica, ele gera os dados inteligentemente. Terceiro, use-o em funções que processam dados não confiáveis: parsers, validadores, codecs—qualquer coisa onde um input inesperado pode quebrar seu programa. Comece simples com round-trip tests e propriedades óbvias, depois evolua para lógica mais complexa conforme ganhar experiência.

Referências


Artigos relacionados