Go Fuzz Testing: Built-In Fuzzing

Unit tests verify that your code handles expected inputs correctly. Fuzz testing verifies that your code doesn't explode when given unexpected inputs. The difference matters more than most developers...

Key Insights

  • Go 1.18 introduced native fuzz testing that integrates seamlessly with the standard testing package, eliminating the need for third-party tools and complex setup
  • Effective fuzz testing targets functions that parse, decode, or validate untrusted input—these are where edge cases and security vulnerabilities hide
  • The coverage-guided fuzzer automatically saves crashing inputs to testdata/fuzz, turning discovered bugs into permanent regression tests

Introduction to Fuzz Testing

Unit tests verify that your code handles expected inputs correctly. Fuzz testing verifies that your code doesn’t explode when given unexpected inputs. The difference matters more than most developers realize.

Fuzz testing (or fuzzing) automatically generates random, mutated inputs and feeds them to your code, looking for crashes, panics, hangs, or assertion failures. It’s particularly effective at finding edge cases that humans never think to test: malformed UTF-8 sequences, integer overflows, deeply nested structures, and boundary conditions that slip through code review.

Security researchers have used fuzzing for decades to find vulnerabilities. Google’s OSS-Fuzz project has found over 10,000 bugs in critical open-source projects. The technique works because computers are better than humans at generating weird inputs, and they don’t get bored after testing a few thousand cases.

Go’s Native Fuzzing Support

Before Go 1.18, fuzzing Go code required third-party tools like go-fuzz or dvyukov/go-fuzz. These worked, but they required separate build steps, custom instrumentation, and didn’t integrate with the standard testing workflow.

Go 1.18 changed this by adding fuzzing directly to the testing package. Native fuzzing offers several advantages:

  • Zero setup: No additional tools to install or configure
  • Familiar interface: Uses the same patterns as regular tests
  • Integrated coverage: The fuzzer uses Go’s built-in coverage instrumentation
  • Corpus management: Automatic handling of test inputs and crash reproduction

A fuzz test looks almost identical to a regular test:

func FuzzReverse(f *testing.F) {
    // Add seed corpus
    f.Add("hello")
    f.Add("world")
    
    // Define the fuzz target
    f.Fuzz(func(t *testing.T, input string) {
        reversed := Reverse(input)
        doubleReversed := Reverse(reversed)
        
        if input != doubleReversed {
            t.Errorf("double reverse mismatch: %q != %q", input, doubleReversed)
        }
    })
}

The function name must start with Fuzz and take a *testing.F parameter. Inside, you provide seed values and define a fuzz target that receives generated inputs.

Writing Your First Fuzz Test

Let’s write a fuzz test for a real-world scenario: a function that parses user-provided configuration values. This is exactly the kind of code where fuzzing shines—it handles untrusted input and has complex parsing logic.

// config.go
package config

import (
    "fmt"
    "strconv"
    "strings"
)

type Duration struct {
    Value int
    Unit  string
}

// ParseDuration parses strings like "30s", "5m", "2h" into a Duration.
func ParseDuration(s string) (Duration, error) {
    s = strings.TrimSpace(s)
    if len(s) < 2 {
        return Duration{}, fmt.Errorf("duration too short: %q", s)
    }
    
    unit := s[len(s)-1:]
    valueStr := s[:len(s)-1]
    
    value, err := strconv.Atoi(valueStr)
    if err != nil {
        return Duration{}, fmt.Errorf("invalid duration value: %w", err)
    }
    
    switch unit {
    case "s", "m", "h", "d":
        return Duration{Value: value, Unit: unit}, nil
    default:
        return Duration{}, fmt.Errorf("unknown unit: %q", unit)
    }
}

Now the fuzz test:

// config_test.go
package config

import (
    "testing"
    "unicode/utf8"
)

func FuzzParseDuration(f *testing.F) {
    // Seed corpus with valid inputs
    f.Add("30s")
    f.Add("5m")
    f.Add("24h")
    f.Add("7d")
    
    // Seed with edge cases we know about
    f.Add("0s")
    f.Add("999999h")
    f.Add("")
    f.Add("s")
    f.Add("-5m")
    
    f.Fuzz(func(t *testing.T, input string) {
        // Skip invalid UTF-8 to focus on logical bugs
        if !utf8.ValidString(input) {
            return
        }
        
        result, err := ParseDuration(input)
        
        if err != nil {
            // Error path: just ensure we don't panic
            return
        }
        
        // Success path: verify invariants
        if result.Value < 0 {
            t.Errorf("negative duration value from %q: %d", input, result.Value)
        }
        
        validUnits := map[string]bool{"s": true, "m": true, "h": true, "d": true}
        if !validUnits[result.Unit] {
            t.Errorf("invalid unit from %q: %q", input, result.Unit)
        }
    })
}

Notice the pattern: we don’t assert that specific inputs produce specific outputs. Instead, we verify invariants—properties that should always hold regardless of input. The fuzzer will find inputs that violate these invariants.

Understanding the Corpus

The corpus is the collection of inputs the fuzzer uses as starting points for mutation. There are two types:

Seed corpus: Inputs you provide via f.Add(). These should include typical valid inputs, known edge cases, and previously discovered bugs.

Generated corpus: Inputs the fuzzer creates and saves when they increase code coverage. These live in testdata/fuzz/<FuzzTestName>/ and persist between runs.

func FuzzJSONParser(f *testing.F) {
    // Different types require different f.Add signatures
    f.Add([]byte(`{"name": "test"}`))
    f.Add([]byte(`[]`))
    f.Add([]byte(`null`))
    f.Add([]byte(`"string"`))
    f.Add([]byte(`12345`))
    f.Add([]byte(`{}`))
    
    // The fuzz target signature must match f.Add
    f.Fuzz(func(t *testing.T, data []byte) {
        var result interface{}
        _ = json.Unmarshal(data, &result)
        // If it doesn't panic, we're happy
    })
}

The fuzzer supports these types for f.Add() and the fuzz target: string, []byte, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, and bool. You can combine multiple parameters:

f.Add("prefix", 100, true)
f.Fuzz(func(t *testing.T, prefix string, count int, enabled bool) {
    // ...
})

Running and Interpreting Fuzz Tests

Run fuzz tests with the -fuzz flag:

# Run all fuzz tests matching the pattern
go test -fuzz=FuzzParseDuration

# Run for a specific duration
go test -fuzz=FuzzParseDuration -fuzztime=30s

# Run with limited parallelism
go test -fuzz=FuzzParseDuration -parallel=4

# Run with minimization disabled (faster but larger crash files)
go test -fuzz=FuzzParseDuration -fuzzminimizetime=0

Typical output during fuzzing:

fuzz: elapsed: 0s, gathering baseline coverage: 0/8 completed
fuzz: elapsed: 0s, gathering baseline coverage: 8/8 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 102834 (34264/sec), new interesting: 12 (total: 20)
fuzz: elapsed: 6s, execs: 238471 (45212/sec), new interesting: 3 (total: 23)

The “new interesting” count shows inputs that increased coverage. When the fuzzer finds a crash:

fuzz: elapsed: 12s, execs: 584721 (48726/sec), new interesting: 0 (total: 23)
--- FAIL: FuzzParseDuration (12.34s)
    --- FAIL: FuzzParseDuration (0.00s)
        config_test.go:42: negative duration value from "-5s": -5
    
    Failing input written to testdata/fuzz/FuzzParseDuration/8a3b2c1d
    To re-run:
    go test -run=FuzzParseDuration/8a3b2c1d

Handling Crashes and Fixing Bugs

When fuzzing discovers a failure, Go saves the input to testdata/fuzz/<FuzzTestName>/. This file becomes a permanent regression test—every future go test run will include it.

Looking at our example, the fuzzer found that “-5s” produces a negative duration. Let’s fix it:

func ParseDuration(s string) (Duration, error) {
    s = strings.TrimSpace(s)
    if len(s) < 2 {
        return Duration{}, fmt.Errorf("duration too short: %q", s)
    }
    
    unit := s[len(s)-1:]
    valueStr := s[:len(s)-1]
    
    value, err := strconv.Atoi(valueStr)
    if err != nil {
        return Duration{}, fmt.Errorf("invalid duration value: %w", err)
    }
    
    // Fix: reject negative values
    if value < 0 {
        return Duration{}, fmt.Errorf("duration cannot be negative: %d", value)
    }
    
    switch unit {
    case "s", "m", "h", "d":
        return Duration{Value: value, Unit: unit}, nil
    default:
        return Duration{}, fmt.Errorf("unknown unit: %q", unit)
    }
}

Now run the tests normally:

go test -v

The saved crash input runs as a regular test case, verifying the fix works.

Best Practices and Limitations

What to fuzz: Focus on functions that handle untrusted input. Parsers, decoders, validators, and serializers are prime targets. Functions with complex branching logic benefit most from coverage-guided fuzzing.

Write good invariants: The fuzzer only finds bugs you can detect. Think about properties that should always hold: reversibility, idempotency, bounds, type constraints, and consistency between related operations.

Keep fuzz targets fast: The fuzzer runs millions of iterations. A target that takes 100ms per iteration will explore far less than one taking 100μs.

Use appropriate seed values: Good seeds help the fuzzer reach interesting code paths faster. Include valid inputs, boundary values, and known problematic cases.

Current limitations:

  • Only supports basic types (no structs, maps, or slices of structs)
  • No built-in support for stateful fuzzing or protocol fuzzing
  • Coverage guidance works at the package level, not across dependencies

When fuzzing adds value: Fuzz testing is not a replacement for unit tests. Use it alongside traditional tests when you need confidence that code handles arbitrary input safely. A few hours of fuzzing often finds bugs that years of unit testing missed.

Start with your most critical parsing code. Run the fuzzer overnight. Fix what it finds. Your future self will thank you when that malformed input hits production.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.