# How to turn fuzz findings into regression tests

Turning fuzz findings into regression tests means keeping each crashing input, reducing it, and adding it to the test suite, so the fixed bug stays fixed.

Last updated September 29, 2026, 9 min read

## Learning objectives

After reading this article you will be able to:

-   List the steps from fuzz crash to regression test
-   Explain where saved inputs live in a project
-   Check that a saved input fails before the fix

## Related content

-   [What is debugging?](https://specstory.com/learning/debugging/debugging)
-   [What is delta debugging?](https://specstory.com/learning/debugging/delta-debugging)
-   [What is a minimal reproducible example?](https://specstory.com/learning/debugging/minimal-reproducible-example)
-   [What are reproduction steps in a bug report?](https://specstory.com/learning/debugging/reproduction-steps)
-   [How to verify a bug fix](https://specstory.com/learning/debugging/bug-fix-verification)

## 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](https://specstory.com/learning/test-quality/fuzzing) into a [regression test](https://specstory.com/learning/testing/regression-testing), 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](https://go.dev/doc/security/fuzz/) 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](https://specstory.com/learning/debugging/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 writes `crash-<sha1>`, `leak-<sha1>`, or `timeout-<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](https://specstory.com/learning/ai-coding/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](https://go.dev/doc/tutorial/fuzz) shows:

```text
$ 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:

```text
$ 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](https://specstory.com/learning/debugging/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](https://specstory.com/learning/debugging/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:

```go
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:

```bash
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.

Diagram: Where Go keeps fuzz inputs

Only the seed corpus works as a regression suite. An input in the build cache runs again only when someone fuzzes on that machine, unless it is copied into the repository.

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](https://pkg.go.dev/testing) 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](https://specstory.com/learning/ci-cd/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](https://llvm.org/docs/LibFuzzer.html) 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.Skip` for inputs with no space reports the saved input as skipped, and `go test` still 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](https://specstory.com/learning/test-quality/catching-tests), 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](https://specstory.com/learning/test-quality/fail-before-pass-after). Restore the old source and run the one input:

```bash
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/fuzz` lists the file, so a fresh clone has it.
-   A verbose CI run, `go test -v`, shows `FuzzParseAddress/number-without-street` as 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](https://specstory.com/learning/debugging/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.

---

Source: [How to turn fuzz findings into regression tests | SpecStory](https://specstory.com/learning/debugging/fuzz-findings-regression-tests)
