Right, so you’re tired of writing test cases for every bizarre little edge case your functions might encounter. You’re a programmer, not a fortune teller. You can’t possibly predict every weird way a user (or an attacker) is going to throw data at your code. This is where fuzzing, or fuzz testing, waltzes in, hands you a drink, and says, “Relax, I’ll handle this.”

Introduced in Go 1.18, the testing.F type is your new best friend for automated chaos. The concept is beautifully simple: you define a fuzz target—a function that accepts a series of random inputs—and the Go tooling will spend as long as you let it, throwing the digital equivalent of spaghetti at the wall to see what sticks. More importantly, it’s watching to see what breaks. It’s a tireless, infinitely creative, and slightly malicious intern dedicated to breaking your code in ways you never imagined.

Here’s the basic skeleton. You don’t write a TestXxx function; you write a FuzzXxx function. It takes a *testing.F instead of a *testing.T.

func FuzzParseQuery(f *testing.F) {
    // 1. Add seed corpus (optional but highly recommended)
    f.Add("name=alice&age=30")

    // 2. Define the fuzz target
    f.Fuzz(func(t *testing.T, query string) {
        result, err := url.ParseQuery(query)
        if err != nil {
            // It's okay if some inputs are invalid; that's the point!
            // We just don't want a panic.
            return
        }
        // Do something with the result to make sure it's sane.
        // If we just ignore it, the compiler might optimize the call away!
        if len(result) == 0 {
            return
        }
    })
}

The Two-Step Fuzz Dance

First, you provide seed corpus. This is crucial. The fuzzer isn’t starting from complete randomness; it starts from examples you give it via f.Add. These are valid, working inputs that teach the fuzzer the basic structure of the data it should be mutating. It will take your beautiful “name=alice&age=30” and create monstrosities like “name=\x00&age=–1” or “aaaaaaaaaaaaaaaaaaaa…”. You can add as many seed examples as you want, and the fuzzer will smartly combine and mutate them.

Second, you define the target function itself with f.Fuzz. Its signature must be a function that takes a *testing.T and then one or more specific types. The allowed types are strictly limited: string, []byte, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, and bool. This isn’t an arbitrary choice; these are types that the fuzzing engine can efficiently and meaningfully generate random data for.

Why You Can’t Fuzz Everything

Notice the type restrictions. You can’t directly fuzz a func(t *testing.T, user struct{Name string}). This is the single biggest “gotcha” and it’s a deliberate design constraint. The fuzzer works with raw, primitive data. Your job is to translate that primitive data into the complex types your function needs. This is a feature, not a bug. It forces you to write an adapter that is itself a potential source of bugs—which the fuzzer can then also find!

Let’s say you have a function that processes a User struct. You have to build that struct from the fuzzer’s raw ingredients.

type User struct {
    Name string
    Age  int
}

func FuzzProcessUser(f *testing.F) {
    f.Add("Alice", 30) // Seed corpus: (string, int)

    f.Fuzz(func(t *testing.T, name string, age int) {
        // Construct the complex type from the primitive inputs
        user := User{Name: name, Age: age}

        // Now we can test our function. We're not testing the adapter,
        // we're testing the core logic of ProcessUser.
        err := ProcessUser(user)

        // Again, some errors might be expected (e.g., validation failures).
        // We're primarily looking for panics or nil pointer dereferences.
        if err != nil {
            // Expected error for invalid data, just return.
            return
        }
        // If it didn't error, maybe do a basic sanity check?
    })
}

Running the Mayhem

You run it with go test -fuzz=FuzzTestName. But here’s the pro move: you’ll almost always want to run it with -fuzztime. Letting it run forever is great for a CI pipeline, but for local development, give it a time limit. go test -fuzz=FuzzParseQuery -fuzztime=30s lets it do its thing for 30 seconds. If it finds a failure, it minimizes the input that caused the crash and writes it to your testdata directory as a new, permanent regression test. This is pure magic. The next time you run go test (even without the -fuzz flag), it will run that specific failing case to ensure you’ve fixed the bug.

The Golden Rule: It Must Not Panic

The primary goal of your fuzz target is to find inputs that cause a panic. Crashes. Nil pointer dereferences. Index-out-of-range errors. The fuzzer is exceptional at this. It is not as good at finding logical errors where your function returns the wrong answer instead of blowing up. For that, you still need well-crafted unit tests. Think of fuzzing as a complement to your test suite, not a replacement. It’s the stress test that finds the cracks in your foundation that your careful blueprints never anticipated. Now go break something. On purpose.