Software bugs do not always appear when developers use normal input values. Sometimes, an application fails because a string contains unexpected characters, a request includes invalid data, or a parser receives an input that nobody considered during development.
These problems can be difficult to find with traditional unit tests because developers usually write tests for inputs they already expect.
Go provides built-in fuzz testing to help discover these unexpected cases. A fuzzer generates different inputs, runs them through your code, and looks for failures such as crashes, incorrect results, or violated assumptions.
One of the most useful applications of fuzz testing is turning a production bug into a permanent regression test. Once you identify an input that causes a failure, you can add it to your test suite so the same problem does not return in a future release.
This article explains how Go fuzz testing works, how to reproduce a bug, and how to build a practical regression-testing workflow.
What Is Fuzz Testing in Go?
Fuzz testing is a testing technique in which software receives automatically generated inputs instead of relying only on manually written test cases.
Go supports native fuzz testing through the testing package. Fuzz tests use functions with names that begin with Fuzz, and developers run them with the standard go test command.
Consider a function that converts a string into an integer.
package parser
import (
"strconv"
)
func ParseNumber(input string) (int, error) {
return strconv.Atoi(input)
}
A traditional unit test might check a valid number and an invalid string.
package parser
import "testing"
func TestParseNumber(t *testing.T) {
value, err := ParseNumber("123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if value != 123 {
t.Fatalf("expected 123, got %d", value)
}
_, err = ParseNumber("hello")
if err == nil {
t.Fatal("expected an error for invalid input")
}
}
These tests cover two useful cases, but they do not explore every possible string.
A fuzz test can generate many additional inputs, including empty strings, unusually long values, unexpected characters, and strings that contain boundary values.
The purpose is not to replace unit tests. It is to discover cases that developers might not think to write manually.
Why Turn Production Bugs Into Regression Tests?
Imagine that your application processes a customer identifier from an API request.
Most requests contain normal identifiers, but one unusual input causes a panic. The issue reaches production because the test suite never covered that input.
After reproducing the problem, you fix the function. However, the fix alone does not guarantee that the problem will never return.
A future refactoring might accidentally introduce the same bug.
A regression test protects against this possibility by recording the input that caused the failure and checking that the corrected code continues to behave properly.
A useful workflow looks like this:
Reproduce the production failure in a controlled environment.
Identify the smallest input that still causes the problem.
Add the input to a Go fuzz test as a seed.
Run the test and confirm that the original bug is reproduced.
Fix the implementation.
Run the fuzz test again and verify that the failure no longer occurs.
Keep the regression input in the repository.
This approach converts an unexpected production problem into a repeatable test case.
Step 1: Create a Fuzz Test
Let's create a simple function that validates a username.
package username
import (
"errors"
"strings"
)
func NormalizeUsername(input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", errors.New("username cannot be empty")
}
if len(value) > 30 {
return "", errors.New("username is too long")
}
return strings.ToLower(value), nil
}
The function removes surrounding whitespace, rejects empty values, checks the length, and converts the username to lowercase.
Now create a fuzz test in a file named username_test.go.
package username
import (
"testing"
)
func FuzzNormalizeUsername(f *testing.F) {
f.Add("Alice")
f.Add(" Bob ")
f.Add("")
f.Add("A username that is much too long")
f.Fuzz(func(t *testing.T, input string) {
_, _ = NormalizeUsername(input)
})
}
The FuzzNormalizeUsername function is a fuzz test because its name begins with Fuzz.
The f.Add() calls provide seed inputs. Go uses these seeds as starting points when exploring additional inputs.
Inside f.Fuzz(), the callback receives generated strings and passes them to the function being tested.
This first example is useful for checking whether the function panics or crashes, but it does not fully verify correctness. A stronger test should check the expected behavior and any important invariants.
Step 2: Add Assertions to the Fuzz Test
Fuzz testing becomes more valuable when the test verifies properties that should always hold.
For the username example, we can check that successful results are nonempty, contain no surrounding whitespace, and use lowercase letters.
package username
import (
"strings"
"testing"
)
func FuzzNormalizeUsername(f *testing.F) {
f.Add("Alice")
f.Add(" Bob ")
f.Add("")
f.Add("A username that is much too long")
f.Fuzz(func(t *testing.T, input string) {
result, err := NormalizeUsername(input)
if err != nil {
return
}
if result == "" {
t.Fatal("successful result must not be empty")
}
if result != strings.TrimSpace(result) {
t.Fatal("result must not contain surrounding whitespace")
}
if result != strings.ToLower(result) {
t.Fatal("result must be lowercase")
}
if len(result) > 30 {
t.Fatal("result must not exceed 30 bytes")
}
})
}
The test checks properties rather than predicting the exact output for every possible input.
For example, a successful result must always be lowercase. If the fuzzer discovers an input that violates this rule, the test fails and Go records the input responsible for the failure.
One detail matters here: Go's built-in len() function measures the number of bytes in a string, not the number of Unicode characters. If your username limit is defined in characters rather than bytes, the implementation and tests should use the intended counting rule.
Step 3: Run the Fuzz Test
Go uses a dedicated command-line flag to run fuzzing.
Run the following command from the module directory:
go test -fuzz=FuzzNormalizeUsername -fuzztime=30s
This tells Go to fuzz the matching test for up to 30 seconds.
You can also run a specific package:
go test ./username -fuzz=FuzzNormalizeUsername -fuzztime=30s
Adjust the package path to match your project structure.
During fuzzing, Go generates inputs and executes the test callback. When it finds a failing input, it reports the failure and saves the input in the fuzz test corpus.
The duration controls how long fuzzing runs. A longer run may discover more cases, but it does not guarantee that every possible input will be tested.
Run Only the Seed Tests
If you want to execute the seed corpus as ordinary tests without starting a fuzzing session, use:
go test ./username
This is useful for checking that the fuzz test compiles and that its existing seed cases pass.
Step 4: Turn a Production Bug Into a Seed
Suppose a production issue occurs when a username contains a special Unicode character.
After reproducing the problem safely, reduce the input to the smallest value that still causes the failure.
You can then add it to the fuzz test:
func FuzzNormalizeUsername(f *testing.F) {
f.Add("Alice")
f.Add(" Bob ")
f.Add("")
f.Add("A username that is much too long")
f.Add("example\u200Buser")
f.Fuzz(func(t *testing.T, input string) {
_, _ = NormalizeUsername(input)
})
}
The final seed contains a zero-width space. It is an example of an input that can be difficult to notice when reading logs or debugging text manually.
The input above is illustrative. It should only be used as a production regression case if it matches the actual failure being investigated.
For a real incident, preserve the relevant input and expected behavior while removing passwords, tokens, personal information, and other sensitive data.
What Happens When a Fuzz Test Finds a Failure?
When Go discovers a failing input, it saves the minimized input in the fuzz corpus, typically under a directory such as:
testdata/
fuzz/
FuzzNormalizeUsername/
<generated-corpus-file>
The exact generated filename depends on the input.
Go can then use the saved corpus entry in later test runs. This makes the failure reproducible and helps prevent the same problem from silently returning.
Do not assume every reported failure is automatically a production bug. Some failures may reveal incorrect test assumptions, invalid input handling, or behavior that the application is not required to support.
Review the input and determine what the correct behavior should be before changing the implementation.
Step 5: Fix the Bug and Verify the Regression Test
After identifying the root cause, update the implementation to handle the problematic input correctly.
For example, suppose the requirement is to reject usernames containing Unicode control or format characters. The implementation should explicitly enforce that requirement instead of relying only on trimming and lowercasing.
A simple implementation can use Unicode character classification:
package username
import (
"errors"
"strings"
"unicode"
)
func NormalizeUsername(input string) (string, error) {
value := strings.TrimSpace(input)
if value == "" {
return "", errors.New("username cannot be empty")
}
if len(value) > 30 {
return "", errors.New("username is too long")
}
for _, r := range value {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
return "", errors.New("username contains unsupported characters")
}
}
return strings.ToLower(value), nil
}
This implementation rejects Unicode control characters and characters in the Unicode format category.
It is an example of a possible policy, not a universal username-validation rule. Some applications may legitimately allow certain format characters, so define the accepted character set according to the application's requirements.
The next step is to run the tests again:
go test ./username
Then run fuzzing:
go test ./username -fuzz=FuzzNormalizeUsername -fuzztime=30s
The regression seed should now pass according to the intended behavior. The fuzzer may also discover other inputs that reveal additional problems.
Fuzz Testing Compared With Traditional Unit Testing
Fuzz tests and unit tests serve different purposes.
Feature | Unit testing | Fuzz testing |
|---|
Input selection | Usually written manually | Generated automatically from seeds and mutations |
Main purpose | Verify known scenarios | Discover unexpected input cases |
Execution | Usually quick and repeatable | May run for a configured duration |
Failure reproduction | Uses the test case written by the developer | Can save a failing input to the corpus |
Best use | Business rules and known requirements | Parsers, validators, encoders, and input-processing code |
Both approaches should be used together.
Unit tests document expected behavior. Fuzz tests explore a broader input space and can expose cases that were not considered when the original tests were written.
Best Practices for Go Fuzz Testing
1. Start With Small, Focused Functions
Fuzzing works particularly well for functions that process input and produce a result.
Examples include:
Smaller functions make failures easier to reproduce and investigate.
2. Add Realistic Seed Inputs
Seeds guide the initial exploration. Include normal values, empty values, boundary cases, and known regression inputs.
For parsers, useful seeds might include valid documents, truncated documents, malformed headers, and unexpected field values.
3. Test Properties, Not Just Crashes
A function can produce the wrong result without panicking.
Check invariants such as output validity, round-trip behavior, length limits, and consistency between related operations.
For example, if an encoder and decoder are designed to support a value, you can test whether decoding an encoded value returns the original value.
4. Keep Regression Inputs in Source Control
When Go finds a failure, review the generated corpus entry and keep it in the repository if it represents a useful regression case.
This allows future test runs to exercise the input again.
5. Avoid Sensitive Production Data
Do not place raw production requests, access tokens, passwords, or personal information into a repository's fuzz corpus.
Reduce the input to the smallest reproduction possible and remove confidential details while preserving the failure.
6. Run Fuzzing in Continuous Integration
A practical CI strategy is to run the normal test suite on every change and schedule longer fuzzing sessions separately.
For example, a short fuzzing run may be suitable for a dedicated CI job:
go test ./... -run=^$
go test ./username -fuzz=FuzzNormalizeUsername -fuzztime=10s
The first command checks package tests differently from a normal test run because -run=^$ selects no ordinary test functions. It can still help identify compilation and test setup issues, but it is not a replacement for running the complete test suite.
A production pipeline should also include the normal test command:
go test ./...
Choose fuzzing duration according to CI time limits and the risk associated with the code.
Common Mistakes and Troubleshooting
The Fuzz Test Does Not Start
Check that the test function begins with Fuzz, is located in a _test.go file, and uses a supported fuzz-test signature.
Also verify that the installed Go version supports native fuzzing.
The Fuzzer Finds a Failure That Is Hard to Understand
Start with the reported corpus input and reproduce it outside the full application if possible.
Inspect the smallest failing input and determine whether the issue is in the implementation, the test assumptions, or the input requirements.
The Test Passes Locally but Fails in CI
Check whether the failing input has been saved and committed to source control.
Also check for environment-dependent behavior, such as assumptions about the filesystem, locale, current time, or external services. A good fuzz target should ideally be deterministic and independent of production infrastructure.
Fuzzing Uses Too Much Time
Limit the duration with -fuzztime and focus on one fuzz target at a time.
Prioritize functions that process untrusted input or have a history of production failures rather than trying to fuzz every function equally.
Advantages and Disadvantages
Advantages
Discovers unexpected inputs that manually written tests may miss.
Converts discovered failures into reproducible regression cases.
Helps find crashes and incorrect behavior in input-processing code.
Integrates with Go's standard testing tools.
Supports a repeatable process for investigating production bugs.
Disadvantages
Fuzzing does not guarantee that every possible input will be explored.
Long-running fuzz sessions can consume CI resources.
Tests require meaningful assertions to detect incorrect results beyond crashes.
Some failures require careful investigation to distinguish genuine bugs from incorrect test assumptions.
Summary
Go fuzz testing is a practical way to discover unexpected input-related bugs and prevent them from returning.
The workflow is straightforward: write a fuzz target, add representative seed inputs, run the fuzzer, investigate any failure, and preserve useful regression cases in the repository.
When a production bug occurs, reduce the failing input to a small reproduction and add it to the fuzz corpus. Fix the underlying issue, then run both the normal test suite and the fuzz test again.
Combining unit tests with fuzz testing gives Go applications stronger protection against malformed input, edge cases, and regressions that might otherwise reach production.
Join the conversation! Your thoughts help the community grow.