Skip to content
Codeloom
Go

Go Fuzz Testing Tutorial

Write fuzz tests in Go to discover edge cases and bugs automatically. Learn the fuzzing API, corpus management, and fixing found crashes.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • How Go's built-in fuzzing engine works
  • Writing fuzz functions with seed corpus entries
  • Running fuzz tests and interpreting results
  • Fixing bugs found by the fuzzer and preventing regressions

Prerequisites

None — this post is self-contained.

Unit tests verify behavior for inputs you think of. Fuzz tests verify behavior for inputs you did not think of. Since Go 1.18, the standard testing package includes a fuzzing engine that generates random inputs, feeds them to your code, and reports any that cause panics, crashes, or assertion failures. This catches edge cases that hand-written tests miss.

Your First Fuzz Test

A fuzz test is a function that starts with Fuzz and takes *testing.F as its parameter. You add seed corpus entries with f.Add() and define the fuzz target with f.Fuzz():

// reverse.go
package str

func Reverse(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)
}
// reverse_test.go
package str

import (
    "testing"
    "unicode/utf8"
)

func FuzzReverse(f *testing.F) {
    // Seed corpus: known inputs that exercise basic behavior
    f.Add("hello")
    f.Add("world")
    f.Add("")
    f.Add("a")
    f.Add("ab")

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

        // Property 1: reversing twice returns the original
        doubleReversed := Reverse(reversed)
        if doubleReversed != input {
            t.Errorf("double reverse mismatch: %q -> %q -> %q",
                input, reversed, doubleReversed)
        }

        // Property 2: length is preserved
        if len(reversed) != len(input) {
            t.Errorf("length changed: input %d, reversed %d",
                len(input), len(reversed))
        }

        // Property 3: reversed string is valid UTF-8 if input is
        if utf8.ValidString(input) && !utf8.ValidString(reversed) {
            t.Errorf("reversed string is invalid UTF-8: %q", reversed)
        }
    })
}

Running the Fuzzer

Run fuzz tests with the -fuzz flag:

go test -fuzz=FuzzReverse

The fuzzer runs indefinitely, generating random strings and checking your properties. To limit the duration:

go test -fuzz=FuzzReverse -fuzztime=30s

Run seed corpus entries as regular tests (without fuzzing):

go test -run=FuzzReverse

This executes the f.Add() entries through the fuzz target, which is useful in CI where you do not want open-ended fuzzing.

Understanding the Output

While fuzzing, you see output like:

fuzz: elapsed: 3s, execs: 28394 (9464/sec), new interesting: 12 (total: 17)
fuzz: elapsed: 6s, execs: 59211 (10283/sec), new interesting: 14 (total: 19)
  • execs: total inputs tested
  • new interesting: inputs that increased code coverage and were added to the corpus

When the fuzzer finds a failing input:

--- FAIL: FuzzReverse (0.85s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:27: reversed string is invalid UTF-8: "\xc0\xb1"

    Failing input written to testdata/fuzz/FuzzReverse/a1b2c3d4

The Corpus Directory

Failing inputs are saved to testdata/fuzz/<FuzzFuncName>/. Each file contains the input that triggered the failure:

go test fance input #1
string("café\xfe")

These files are automatically included in future test runs. Once you fix the bug, the previously-failing input becomes a regression test. Commit the testdata/fuzz/ directory to version control to preserve these discoveries.

Supported Parameter Types

The fuzz target function can accept these types as parameters:

  • string, []byte
  • int, int8, int16, int32, int64
  • uint, uint8, uint16, uint32, uint64
  • float32, float64
  • bool

Each f.Add() call must provide values matching the fuzz target’s parameter list:

func FuzzParseAge(f *testing.F) {
    f.Add("25")
    f.Add("-1")
    f.Add("0")
    f.Add("999")
    f.Add("not a number")

    f.Fuzz(func(t *testing.T, input string) {
        age, err := ParseAge(input)
        if err != nil {
            return // invalid input is acceptable
        }
        if age < 0 || age > 150 {
            t.Errorf("ParseAge(%q) = %d, out of valid range", input, age)
        }
    })
}

Fuzzing a JSON Parser

Fuzzing is particularly effective for parsers and deserializers:

type Config struct {
    Name    string `json:"name"`
    Port    int    `json:"port"`
    Debug   bool   `json:"debug"`
}

func ParseConfig(data []byte) (*Config, error) {
    var c Config
    if err := json.Unmarshal(data, &c); err != nil {
        return nil, err
    }
    if c.Name == "" {
        return nil, fmt.Errorf("name is required")
    }
    if c.Port < 1 || c.Port > 65535 {
        return nil, fmt.Errorf("port must be 1-65535, got %d", c.Port)
    }
    return &c, nil
}
func FuzzParseConfig(f *testing.F) {
    f.Add([]byte(`{"name":"app","port":8080,"debug":false}`))
    f.Add([]byte(`{}`))
    f.Add([]byte(`invalid json`))
    f.Add([]byte(`{"name":"","port":0}`))

    f.Fuzz(func(t *testing.T, data []byte) {
        config, err := ParseConfig(data)
        if err != nil {
            return // parser correctly rejected input
        }

        // If parsing succeeded, validate invariants
        if config.Name == "" {
            t.Error("parsed config has empty name")
        }
        if config.Port < 1 || config.Port > 65535 {
            t.Errorf("parsed config has invalid port: %d", config.Port)
        }

        // Round-trip: marshal and parse again
        reencoded, err := json.Marshal(config)
        if err != nil {
            t.Fatalf("failed to re-marshal: %v", err)
        }
        config2, err := ParseConfig(reencoded)
        if err != nil {
            t.Fatalf("failed to re-parse: %v", err)
        }
        if config.Name != config2.Name || config.Port != config2.Port {
            t.Error("round-trip mismatch")
        }
    })
}

Fuzzing With Multiple Parameters

Test functions that take multiple inputs by adding multiple parameters:

func FuzzContains(f *testing.F) {
    f.Add("hello world", "world")
    f.Add("foo", "bar")
    f.Add("", "")
    f.Add("abc", "")

    f.Fuzz(func(t *testing.T, haystack, needle string) {
        result := strings.Contains(haystack, needle)

        // Property: if needle is empty, result must be true
        if needle == "" && !result {
            t.Error("Contains should return true for empty needle")
        }

        // Property: if result is true, haystack length >= needle length
        if result && len(haystack) < len(needle) {
            t.Error("Contains true but haystack shorter than needle")
        }
    })
}

Writing Good Fuzz Properties

The fuzz target must check properties, not specific expected outputs. Good properties include:

  • Round-trip: encode then decode, or transform then reverse, and verify you get the original.
  • Invariants: output length, valid UTF-8, values within expected ranges.
  • No panics: the function should not crash on any input, even garbage.
  • Consistency: calling the same function twice with the same input should produce the same result.
  • Comparison: if you have two implementations, they should agree on all inputs.

Integrating Fuzzing Into CI

Continuous fuzzing runs indefinitely, but CI needs bounded execution. Run seed corpus entries as regular tests and periodically fuzz for a fixed duration:

jobs:
  test:
    steps:
      - run: go test ./...            # Runs seed corpus entries
  fuzz:
    steps:
      - run: go test -fuzz=. -fuzztime=2m ./...

Commit any new corpus entries found by local fuzzing sessions to keep the seed corpus growing over time.

Wrap Up

Go’s built-in fuzzing engine generates random inputs and checks them against properties you define. It discovers edge cases in parsers, validators, and transformations that hand-written tests miss. Failing inputs are saved as corpus entries that become permanent regression tests. Add fuzz tests for any function that processes untrusted input, and run them regularly to catch bugs before your users do.