How to Use the testing/quick Package for Fuzz-Like Tests

Use testing.F and f.Fuzz for fuzzing in Go, not the deprecated testing/quick package.

The gap between unit tests and reality

You write a function that parses a CSV line. It handles commas, quotes, and newlines. You write ten unit tests. They pass. You deploy it. Three days later, a user uploads a file with a zero-width space followed by a carriage return, and your parser panics. Manual testing covers the happy path and the obvious edge cases. It does not cover the weird combinations that only appear in the wild. You need a tool that throws random garbage at your code until it breaks.

Go used to rely on the testing/quick package for property-based testing. That package generated random inputs and checked if a mathematical property held true. It was useful, but it was not fuzzing. Modern Go has a built-in fuzzing engine. You define a fuzz target, provide a few seed values, and the test runner mutates those seeds into millions of variations. It tracks which code paths get hit, focuses mutation on unexplored branches, and automatically shrinks failing inputs to their smallest form. The old testing/quick package is legacy. The standard approach now is testing.F and the f.Fuzz method. Fuzzing is not about proving correctness. It is about finding the exact input that breaks your assumptions.

Unit tests verify known behavior. Fuzzing discovers unknown failure modes. Keep them separate.

How fuzzing actually works

The fuzzing engine operates on a feedback loop. It starts with a seed corpus. It runs your callback function. It records which basic blocks in your code executed. It mutates the input bytes. It runs the callback again. If the new input triggers a previously unexecuted code path, the engine saves it to the corpus. If the input causes a panic or a test failure, the engine shrinks it until the failure reproduces with the minimal payload. This cycle repeats until you stop it or it exhausts the input space.

The engine uses coverage-guided mutation. It does not mutate randomly. It prioritizes changes that are likely to hit new branches. It tracks edge coverage at the basic block level. It assigns a score to each corpus entry based on how many unique paths it exercises. It prunes entries that no longer contribute new coverage. This keeps the corpus small and focused. The mutation strategy combines byte flips, length changes, splice operations, and structural transformations. It respects type boundaries when possible, but it deliberately breaks them to catch deserialization bugs.

Coverage guidance turns brute force into targeted exploration. Let the engine do the heavy lifting.

Your first fuzz target

Here is the simplest fuzz target: define the function, seed it, and hand the mutation logic to the framework.

package main

import "testing"

// FuzzParseNumber generates random strings and checks if the parser handles them safely.
func FuzzParseNumber(f *testing.F) {
    // Seed the corpus with known valid and invalid inputs.
    f.Add("123", "-45", "abc")
    f.Fuzz(func(t *testing.T, input string) {
        // Run the target function. The fuzzer will mutate the input string.
        result, err := ParseNumber(input)
        if err == nil && result < 0 {
            // Fail only when a specific invariant is violated.
            t.Fatalf("expected non-negative result for %q, got %d", input, result)
        }
    })
}

Run it with go test -fuzz=FuzzParseNumber. The test runner starts immediately. It does not wait for you to press enter. It runs until you stop it or it finds a failure. The f.Add calls populate the initial corpus. The f.Fuzz callback defines the test logic. The callback receives a *testing.T for lifecycle management and logging, plus the input arguments that the engine will mutate. Convention aside: fuzz target names must start with Fuzz. The compiler rejects anything else with func FuzzXxx must have signature func(f *testing.F). The receiver is always f, not t. The framework uses f to manage the corpus and mutation engine, while t handles test lifecycle and logging inside the callback.

Seed generously. Mutate aggressively. Fail precisely.

What happens under the hood

The engine follows a strict lifecycle. It loads your seed corpus from the testdata/fuzz/FuzzParseNumber directory. If the directory does not exist, it creates one and saves your initial seeds. It runs the callback function. It records which basic blocks in your code executed. It mutates the input bytes using a combination of random flips, length changes, and structural transformations. If a mutation triggers a new code path, the engine saves that input to the corpus. If a mutation causes a failure, the engine shrinks the input. Shrinking removes bytes one by one until the failure still reproduces with the smallest possible payload. This gives you a minimal reproducible case instead of a megabyte of garbage.

The fuzzer runs in parallel across your CPU cores. Each core maintains its own corpus and mutation state. They merge results periodically. The engine also performs corpus pruning. If an entry no longer exercises unique coverage compared to other entries, the engine removes it. This prevents the corpus from growing indefinitely. The engine also tracks execution time. It stops mutating inputs that take too long to process, preventing denial-of-service style hangs from consuming resources.

The callback runs in isolation. Each invocation gets a fresh *testing.T. The engine does not reuse state between runs. You can safely use t.Setenv, t.TempDir, and t.Cleanup. The framework handles teardown automatically. Convention aside: keep fuzz targets in the same package as the code they test. This gives them access to unexported functions and internal state. Exported-only fuzzing misses half the bugs.

Corpus management is automatic. Trust the pruning algorithm.

A realistic parsing target

Real code rarely takes a single string. You often parse structured data, validate configurations, or process binary protocols. Here is a fuzz target for a simple key-value parser that handles multiple arguments and environment variables.

package main

import (
    "os"
    "testing"
)

// FuzzParseConfig validates that configuration parsing never panics on malformed input.
func FuzzParseConfig(f *testing.F) {
    // Provide realistic seeds to bootstrap the mutation engine.
    f.Add("host=localhost", "port=8080", "debug=true")
    f.Add("", "key=", "=value", "a=b c=d")
    f.Fuzz(func(t *testing.T, raw string, flag string) {
        // Isolate each test run from global state.
        t.Setenv("APP_CONFIG", raw)
        // Run the parser with both the string and a command-line flag.
        cfg, err := ParseConfig(raw, flag)
        if err != nil {
            // Parsing errors are expected for malformed input.
            return
        }
        // Verify that the parsed structure matches the input.
        if cfg.Host == "" && os.Getenv("APP_CONFIG") != "" {
            t.Fatalf("expected host to be set when config is provided")
        }
    })
}

The callback receives multiple arguments. The fuzzer mutates each one independently. It also combines mutations across runs. You can pass slices, structs, or custom types as long as they implement the standard encoding rules. The engine serializes them to bytes, mutates the bytes, and deserializes them back. If deserialization fails, the engine discards that mutation and tries again. This keeps the callback focused on your logic instead of type conversion. The t.Setenv call ensures each run gets a clean environment. The t.Fatalf call stops execution and saves the failing input to the crashers subdirectory.

Multiple arguments multiply the search space. Seed them with realistic combinations.

Pitfalls and compiler traps

Fuzzing exposes different failure modes than unit tests. The most common trap is non-determinism. If your function reads the current time, queries a live database, or relies on a global random seed, the fuzzer will produce flaky results. Isolate external state. Use t.TempDir for file operations. Use testing.Short to skip heavy I/O during quick runs. Another trap is over-failing. Fuzz targets should fail only when an invariant is violated. If you fail on every minor warning, the corpus fills with noise and the engine stops exploring new paths. Let the parser return errors. Fail only when the error contradicts the input or when a panic occurs. The fuzzer catches panics automatically. You do not need defer recover.

If you write func FuzzBad(t *testing.T), the compiler rejects it with func FuzzBad must have signature func(f *testing.F). The signature is strict. The framework needs f to control mutation. You cannot swap it for t. If you forget to import testing, you get undefined: testing from the compiler. If you pass the wrong type to f.Add, the compiler complains with cannot use x (untyped int constant) as string value in argument. The type system enforces consistency before the engine starts.

Convention aside: fuzz targets should not assert on exact output values. They should assert on invariants. Length bounds, memory safety, idempotency, and error handling are good invariants. Exact string matches are not. The fuzzer will mutate inputs until your exact match fails, which teaches you nothing about the underlying logic.

Fail on invariants, not on exact values. Keep the signal clean.

Tuning the engine

The go test command accepts flags that control fuzzing behavior. The -fuzztime flag limits execution duration. The -fuzzminimizetime flag controls how long the engine spends shrinking a failing input. The -cover flag enables coverage tracking, which the fuzzer uses for guidance. You can also run fuzzing in a single thread with -parallel=1 to debug race conditions or deterministic failures. The engine saves new corpus entries to testdata/fuzz/<FuzzTargetName>. You can commit this directory to version control. This shares interesting inputs across your team and preserves bug-reproducing cases for regression testing.

When you find a failure, the engine saves the minimal input to testdata/fuzz/<FuzzTargetName>/crashers. You can replay it with go test -run=FuzzParseNumber/corpus/seed1. This lets you write a permanent unit test for the bug. Fuzzing finds the bug. Unit tests verify the fix. The two strategies complement each other.

Tune the flags. Commit the corpus. Replay the crashers.

When to fuzz and when to stop

Fuzzing is powerful, but it is not a replacement for every test strategy. Use fuzzing when you process untrusted input and need to find edge cases that manual testing misses. Use unit tests when you need to verify specific business logic and guarantee deterministic behavior. Use property-based testing when you want to validate mathematical invariants across a known input space. Use integration tests when you need to verify interactions with databases, networks, or external services. Use manual testing when you are evaluating user experience or visual layout. The simplest thing that works is usually the right thing.

Fuzzing finds the cracks. Unit tests seal them.

Where to go next