Key points
- Saved inputs in the testdata/fuzz folder run with plain go test, but those in the build cache do not.
- Run the saved input on the code before the fix, and confirm that it fails with the reported error.
- Commit the input with the fix, then fuzz again to catch a fix that works only for that input.
How do you turn fuzz findings into regression tests?
To turn a finding from fuzzing into a regression test, keep the input that failed, reduce it, and store it where the normal test run reads it. Check that it fails on the code before the fix and passes after it. Then commit the input with the fix, so each later test run repeats it.
A fuzzer saves the input that crashes the program or fails a check in its harness. Go's fuzzing documentation says such an input then runs by default with go test, "serving as a regression test once the bug has been fixed."
These steps finish the loop of debugging, which reproduces a failure, fixes its cause, and confirms the fix. They use Go's built-in fuzzer and note libFuzzer for C and C++ where it differs.
What do you need before you start?
Turning a finding into a test needs four things:
- A saved failing input. Go writes it to
testdata/fuzz/<FuzzName>/<hash>, and libFuzzer usually writescrash-<sha1>,leak-<sha1>, ortimeout-<sha1>. - The fuzz test that found it. The input runs only through that harness, with the same argument types.
- The failure output. The error and the failing line are what the saved input must reproduce.
- The code before any fix. The saved input must fail there first.
How do you turn fuzz findings into regression tests step by step?
Here is an illustrative example. Acme Co. sells furniture online. A coding agent added a Go function, ParseAddress, that splits "12 Elm Street" into a house number and a street. Go's fuzzer crashed it with a panic and saved one input.
1. Rerun the saved input before changing any code
Go runs each saved file as a subtest named after the file, so -run selects one input without fuzzing, as Go's fuzzing tutorial shows:
$ go test -run=FuzzParseAddress/771e938e4458e983
--- FAIL: FuzzParseAddress (0.00s)
--- FAIL: FuzzParseAddress/771e938e4458e983 (0.00s)
panic: runtime error: index out of range [1] with length 1 [recovered, repanicked]
The input fails the same way on its own, and plain go test now fails too. Keep this output to compare with later runs.
2. Check that the input is reduced
Go's fuzzer reduces a failing input before it saves it, and -fuzzminimizetime limits each attempt, 60 seconds by default. This one holds one character:
$ cat testdata/fuzz/FuzzParseAddress/771e938e4458e983
go test fuzz v1
string("0")
A short input is easier to review and points to the failing line faster. For libFuzzer, reduce the crash file in a separate run with -minimize_crash=1 and a limit, e.g. -max_total_time=60. Without a minimizer, delta debugging automates the same search.
Go saves only the reduced input, and a reduction can end on a different bug, so check that its error and line match the bug you meant to fix. With its harness, the input is a minimal reproducible example of the bug.
3. Give the input a readable name
Rename the file to number-without-street. Go runs each file in the folder whatever its name, and a later failure names the broken case.
4. Fix the cause
The old code read the text after the first space, and the saved input has no space. The agent changes ParseAddress to return an error when there is no street:
func ParseAddress(s string) (Address, error) {
number, street, ok := strings.Cut(s, " ")
if !ok || street == "" {
return Address{}, errors.New("address has no street")
}
return Address{Number: number, Street: street}, nil
}
5. Rerun the input, then fuzz again
Rerun go test -v, which now lists the input as a passing subtest. Then fuzz again with a limit, e.g. -fuzztime=30s, because a fix aimed only at the saved input can fail on a nearby one.
6. Commit the input in the same change as the fix
The fuzzer created the file, so git status --short lists it as untracked, ?? testdata/. Add it with the fix:
git add address.go testdata/fuzz/FuzzParseAddress/number-without-street
git commit -m "Reject addresses with no street"
The regression test and the fix now share one diff. This example is simplified. A real project would also run a longer fuzzing job, often on a schedule.
Where do fuzz regression tests live?
A corpus is the set of saved inputs that a fuzzer starts from and adds to. Go keeps two corpora, and only one of them is a regression suite.
The seed corpus is the f.Add calls in the fuzz test plus the files in testdata/fuzz/<FuzzName>. Plain go test runs each entry even when nobody is fuzzing.
The generated corpus holds the inputs that reached more code while fuzzing. It lives in the build cache, stays on that machine, and runs only during fuzzing.
To keep a generated input, copy its file from $GOCACHE/fuzz/<import path>/<FuzzName> into testdata/fuzz/<FuzzName>. Both corpora use the same file format, and go env GOCACHE prints the cache folder. Go's testing package documents the testdata/fuzz folder.
A unit test that calls ParseAddress("0") and expects an error also keeps the bug fixed, even after the fuzz test changes.
The saved inputs belong in the normal test job of the continuous integration and delivery (CI/CD) pipeline. A fuzzing run has no natural end, so a CI job sets -fuzztime, and teams often run it on a schedule.
libFuzzer writes a crash file to the current directory unless -artifact_prefix sets a path, so teams move it into the repository, e.g. fuzz/regressions. Passing files, not folders, to the fuzzer binary reruns them without fuzzing, which its documentation suggests for CI.
A crash that nobody can fix yet stays out of testdata/fuzz, or go test fails for every change. Attach the input to the bug report, or save it in a test that calls t.Skip with the issue number. Move it into the seed corpus in the change that fixes the bug.
What changes when a coding agent writes the code?
When a coding agent fixes a fuzz crash, its target is a green run of the saved input. Three edits reach that target and leave the bug in place:
- A special case. A check for the saved bytes, e.g.
s == "0", passes while"12"still panics. - A weaker harness. A harness that calls
t.Skipfor inputs with no space reports the saved input as skipped, andgo teststill passes. - A deleted file. When the fuzz function's arguments change, Go fails with a "mismatched types" or "wrong number of values" corpus entry error. Deleting the files clears the error and the tests.
A short fuzzing run on the agent's change serves as a catching test, a check built to fail on a bug that one change introduced. A catching test is usually thrown away after review, but each input the run saves stays as a regression test.
The adjustment is to review the diff for these three edits and fuzz again after the fix. In a fix for a fuzz crash, files under testdata/fuzz should only be added, not deleted or edited.
What are common mistakes?
These mistakes leave a fuzz finding without a working regression test:
- Leaving the input untracked. A change that stages only edited files leaves the fuzzer's file behind, so CI never runs it.
- Renaming the fuzz test. Go reads saved inputs only from the folder named after the fuzz test, so a renamed test drops them with no error. Rename the folder in the same change.
- Saving an input that fails for another reason. A reduced input can hit a different error, e.g. a check for empty input, so the saved test misses the reported bug.
- Changing the fuzz function's arguments. Saved inputs must match its argument types in order, so convert the old files or keep the old fuzz test.
How do you check that it worked?
A saved input works as a regression test when it fails before the fix and passes after it, a fail-before, pass-after check. Restore the old source and run the one input:
git restore --source=HEAD~1 -- address.go
go test -run=FuzzParseAddress/number-without-street
git restore -- address.go
The old source fails with the same index out of range panic, and the fixed source passes. Then confirm three more points:
git ls-files testdata/fuzzlists the file, so a fresh clone has it.- A verbose CI run,
go test -v, showsFuzzParseAddress/number-without-streetas a passing subtest, not a skipped or missing one. - The fuzzing run after the fix reached its time limit without another failure.
Passing saved inputs show that those inputs no longer fail, not that the parser handles every input. To check the behavior next to the fix, follow bug fix verification.
FAQs
Should fuzz tests run in CI?
Fuzz tests belong in CI in two forms. The saved inputs run with the normal tests on each change. A fuzzing run has no natural end, so it gets a time limit and often runs on a schedule instead.
What is a fuzzing corpus?
A fuzzing corpus is the set of saved inputs that a fuzzer starts from and adds to. In Go, only the seed corpus in the repository runs with the normal tests.
Why reduce a crashing input before you save it?
A reduced crashing input is easier to review and points to the failing line faster. Rerun it before you commit it, because a reduction can end on a different error.
What if the fuzzer finds a crash nobody can fix yet?
A crash that nobody can fix yet stays out of the folder the normal tests read, or every test run fails. Attach the input to the bug report, and move it into the seed corpus with the fix.