Fuzz Testing Go Code: Native go test -fuzz and dvyukov/go-fuzz

Fuzz Testing Go Code: Native go test -fuzz and dvyukov/go-fuzz

Go 1.18 shipped something remarkable: built-in fuzz testing integrated directly into go test. No external tools, no compilation flags, no special setup — just write a FuzzXxx function and run go test -fuzz. This guide covers native Go fuzzing, the older dvyukov/go-fuzz library, and how to write fuzz targets that actually find bugs.

Native Go Fuzzing (Go 1.18+)

How It Works

Go's built-in fuzzer uses coverage-guided fuzzing, the same technique as AFL++ and libFuzzer. It instruments your code at compile time, tracks which branches each input exercises, and evolves the corpus toward higher coverage.

The key difference from unit tests: fuzz tests run indefinitely (until you stop them or set a time limit), continually generating new inputs.

Writing a Fuzz Target

A fuzz function has the signature FuzzXxx(f *testing.F):

// parser_fuzz_test.go
package parser_test

import (
    "testing"
    "github.com/yourorg/myparser"
)

// FuzzParseConfig tests that ParseConfig never panics on arbitrary input
func FuzzParseConfig(f *testing.F) {
    // Seed corpus: real inputs that should work
    f.Add(`{"key": "value", "count": 42}`)
    f.Add(`{}`)
    f.Add(`{"nested": {"deep": {"deeper": "value"}}}`)
    
    // The fuzz function receives the generated input
    f.Fuzz(func(t *testing.T, data string) {
        // ParseConfig should handle ANY string without panicking
        // It may return an error, but it must not panic
        config, err := myparser.ParseConfig(data)
        if err != nil {
            return  // Parse errors are expected and OK
        }
        
        // If parse succeeded, serialize should work too
        serialized, err := config.Serialize()
        if err != nil {
            t.Errorf("ParseConfig succeeded but Serialize failed: %v", err)
            return
        }
        
        // Round-trip: re-parse the serialized output
        config2, err := myparser.ParseConfig(serialized)
        if err != nil {
            t.Errorf("Serialize output not re-parseable: %v\nInput: %q\nSerialized: %q", 
                err, data, serialized)
        }
        _ = config2
    })
}

Running Fuzz Tests

# Run unit tests only (not fuzzing mode — runs corpus items as unit tests)
go <span class="hljs-built_in">test ./...

<span class="hljs-comment"># Start fuzzing (runs indefinitely)
go <span class="hljs-built_in">test -fuzz=FuzzParseConfig

<span class="hljs-comment"># Fuzz for a specific duration
go <span class="hljs-built_in">test -fuzz=FuzzParseConfig -fuzztime=5m

<span class="hljs-comment"># Fuzz a specific package
go <span class="hljs-built_in">test -fuzz=FuzzParseConfig ./pkg/parser/

<span class="hljs-comment"># Run with verbose output
go <span class="hljs-built_in">test -fuzz=FuzzParseConfig -v -fuzztime=1m

Output during fuzzing:

fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 84953 (28317/sec), new interesting: 43 (total: 46)
fuzz: elapsed: 6s, execs: 199946 (38331/sec), new interesting: 51 (total: 54)
fuzz: elapsed: 9s, execs: 317259 (39104/sec), new interesting: 54 (total: 57)
  • execs/sec: How fast the fuzzer is running (target executions per second)
  • new interesting: Inputs that triggered new code coverage
  • total: Total corpus size

When a Crash is Found

--- FAIL: FuzzParseConfig (4.23s)
    Failing input written to testdata/fuzz/FuzzParseConfig/1a2b3c4d
    To re-run: go test -run=FuzzParseConfig/1a2b3c4d
FAIL

The failing input is saved to testdata/fuzz/FuzzParseConfig/. This is committed to your repo and automatically becomes a regression test — go test (without -fuzz) runs it on every test invocation.

# Reproduce the failure
go <span class="hljs-built_in">test -run=FuzzParseConfig/1a2b3c4d -v

Multi-Input Fuzz Functions

Your fuzz function can take multiple parameters:

func FuzzNetworkParser(f *testing.F) {
    // Seed with (host, port) pairs
    f.Add("localhost", 8080)
    f.Add("192.168.1.1", 443)
    f.Add("example.com", 80)
    
    f.Fuzz(func(t *testing.T, host string, port int) {
        addr := net.JoinHostPort(host, strconv.Itoa(port))
        // Whatever you're testing with (host, port)
        result := ParseAddress(addr)
        _ = result
    })
}

Supported types: string, []byte, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, rune, byte.

Testing the Go Standard Library Style

Go's standard library uses this pattern extensively. Here's how the encoding/json package is fuzz-tested:

func FuzzUnmarshal(f *testing.F) {
    f.Add(`null`)
    f.Add(`true`)
    f.Add(`{}`)
    f.Add(`[]`)
    f.Add(`{"a":1}`)
    
    f.Fuzz(func(t *testing.T, b []byte) {
        var i interface{}
        if err := json.Unmarshal(b, &i); err != nil {
            return
        }
        
        // Round-trip test
        out, err := json.Marshal(i)
        if err != nil {
            t.Fatalf("marshal after unmarshal failed: %v", err)
        }
        
        var i2 interface{}
        if err := json.Unmarshal(out, &i2); err != nil {
            t.Fatalf("re-unmarshal failed: %v\nInput: %q\nMarshaled: %q",
                err, b, out)
        }
    })
}

Effective Seed Corpus

The seed corpus (in f.Add() calls) matters enormously:

func FuzzHTTPRequestParser(f *testing.F) {
    // Minimal valid requests covering different code paths
    f.Add("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
    f.Add("POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Length: 5\r\n\r\nhello")
    f.Add("GET /path?q=hello+world HTTP/1.0\r\n\r\n")
    
    // Edge cases you already know about
    f.Add("GET / HTTP/1.1\r\nHost: example.com\r\n" + strings.Repeat("X: y\r\n", 100) + "\r\n")
    f.Add("OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n")
    
    f.Fuzz(func(t *testing.T, raw string) {
        req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(raw)))
        if err != nil {
            return
        }
        defer req.Body.Close()
        // Verify invariants on successfully parsed request
        if req.Method == "" {
            t.Error("successfully parsed request has no method")
        }
    })
}

Testdata Corpus from Files

For binary formats, populate the corpus from files:

# Corpus stored in testdata/fuzz/FuzzJPEGParser/
<span class="hljs-built_in">mkdir -p testdata/fuzz/FuzzJPEGParser

<span class="hljs-comment"># Copy real JPEG files as seeds
<span class="hljs-keyword">for f <span class="hljs-keyword">in testdata/samples/*.jpg; <span class="hljs-keyword">do
    <span class="hljs-built_in">cp <span class="hljs-string">"$f" <span class="hljs-string">"testdata/fuzz/FuzzJPEGParser/$(basename $f)"
<span class="hljs-keyword">done

Go automatically uses these files as seed corpus for FuzzJPEGParser.

dvyukov/go-fuzz (Pre-1.18 and Custom Mutators)

Before Go 1.18, dvyukov/go-fuzz was the standard fuzzer for Go. It's still useful for:

  • Go versions before 1.18
  • Custom mutation strategies (Sonar mode)
  • Some Go version-specific behavior

Installation

go install github.com/dvyukov/go-fuzz/go-fuzz@latest
go install github.com/dvyukov/go-fuzz/go-fuzz-build@latest

Writing a go-fuzz Target

// fuzz.go  — must be in a separate file with build tag
//go:build gofuzz

package mypackage

func Fuzz(data []byte) int {
    _, err := ParseMyFormat(data)
    if err != nil {
        // Return 0 to tell go-fuzz this is uninteresting
        return 0
    }
    // Return 1 to tell go-fuzz this input is interesting (add to corpus)
    return 1
}

Building and Running

# Build instrumented binary
go-fuzz-build -o fuzz.zip github.com/yourorg/yourpackage

<span class="hljs-comment"># Run (corpus in workdir/corpus/, crashes in workdir/crashers/)
go-fuzz -bin=fuzz.zip -workdir=fuzz-workdir/

Migration to Native Fuzzing

If you have go-fuzz targets, migrating to native fuzzing is straightforward:

// Old go-fuzz target
func Fuzz(data []byte) int {
    _, err := ParseMyFormat(data)
    if err != nil {
        return 0
    }
    return 1
}

// New native fuzz target
func FuzzMyFormat(f *testing.F) {
    f.Add([]byte{0x00, 0x01, 0x02})  // Seed corpus
    f.Fuzz(func(t *testing.T, data []byte) {
        _, err := ParseMyFormat(data)
        _ = err  // Errors are expected
    })
}

Migrate: copy seed corpus files from fuzz-workdir/corpus/ to testdata/fuzz/FuzzMyFormat/.

Real-World Example: Fuzzing a URL Router

// fuzz_router_test.go
package router_test

import (
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/yourorg/router"
)

func FuzzRouter(f *testing.F) {
    // Seed with various URL patterns
    f.Add("/")
    f.Add("/users/123")
    f.Add("/api/v1/products?page=1&limit=20")
    f.Add("/files/../../../etc/passwd")  // Path traversal attempt
    f.Add("/api/" + strings.Repeat("x", 1000))  // Long path
    
    // Create router
    r := router.New()
    r.GET("/users/:id", func(w http.ResponseWriter, req *http.Request) {
        w.WriteHeader(200)
    })
    r.GET("/api/v1/products", func(w http.ResponseWriter, req *http.Request) {
        w.WriteHeader(200)
    })
    
    f.Fuzz(func(t *testing.T, path string) {
        req := httptest.NewRequest("GET", path, nil)
        w := httptest.NewRecorder()
        
        // Router must never panic
        r.ServeHTTP(w, req)
        
        // Response code must be a valid HTTP status
        code := w.Code
        if code < 100 || code > 599 {
            t.Errorf("invalid status code: %d for path: %q", code, path)
        }
    })
}

CI Integration

# .github/workflows/fuzz.yml
name: Fuzzing

on:
  push:
    branches: [main]
  schedule:
    - cron: '0 3 * * *'

jobs:
  fuzz:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      
      - name: Fuzz ParseConfig
        run: go test -fuzz=FuzzParseConfig -fuzztime=2m ./pkg/parser/
      
      - name: Fuzz HTTPParser
        run: go test -fuzz=FuzzHTTPParser -fuzztime=2m ./pkg/http/
      
      - name: Check for new corpus entries
        run: |
          if git status --porcelain testdata/fuzz | grep -q '??'; then
            git config user.name "Fuzzer Bot"
            git config user.email "fuzzer@example.com"
            git add testdata/fuzz/
            git commit -m "chore: add fuzz corpus entries from CI"
            git push
          fi

Connecting to Production Monitoring

Go fuzzing protects your parsing and processing code from malformed input. Production Go applications fail for different reasons: third-party dependencies, infrastructure issues, data edge cases that only appear at scale.

HelpMeTest monitors your live Go services with continuous end-to-end tests — 24/7 monitoring of the user-facing behavior that fuzzing can't cover. Write tests in plain English, get alerted immediately when something breaks. $100/month, no infrastructure to manage.

Summary

Go's built-in fuzzing is the lowest-friction entry point to fuzz testing in any language:

  • Write a FuzzXxx(f *testing.F) function
  • Add seed corpus with f.Add()
  • Run go test -fuzz=FuzzXxx -fuzztime=5m
  • Failing inputs are saved as regression tests automatically

Start with your input parsers, network protocol handlers, and any code that processes user-provided data. Go fuzzing will find bugs that your unit tests have missed — guaranteed.

Read more