1. Why fuzz tests are needed
After you’ve written a few dozen regular tests, a feeling appears: “That’s it, I’m an adult now, my code is tested.” And then reality politely nods and hands you input like " \t \n-0000000000000000000000007", or a string with null bytes, or just a very long text. Fuzz tests were invented for exactly these moments.
Philosophically, fuzzing relates to “random testing”: we don’t pick 10 known cases in advance; instead, we run automatic generation of many input variants to try to “feel out the edge.” This is useful for problems where “bad inputs” occur more often than you’d think, and where failures show up in strange ways: panic, hang, overflow, incorrect normalization.
Important: fuzz tests are built into standard go test (support appeared in Go 1.18 and continued evolving).
How a fuzz test differs from a unit test
When you write a regular test, you are like a director: you pick the scene, the characters, and the lines. This works great when you have a manageable number of variants. For example, Add(1,2)=3 is perfect. But as soon as a function starts accepting strings, there are too many “scenes”: spaces, tabs, minus signs, plus signs, emptiness, garbage characters, huge length, different languages, unexpected separators.
A fuzz test is when you remain the director in only one thing: you formulate a rule that must always be true (or “almost always,” given preconditions). And then the generator picks inputs for you: it feeds you a huge number of variants and checks whether you violated your rule.
A good mental model is:
- a unit test checks “a specific example → a specific answer”;
- a fuzz test checks “for many inputs, a property (invariant) holds.”
The key word here is invariant.
Invariant: how to come up with a check you’re not embarrassed to run
An invariant is a statement about a function’s behavior that must remain true for all inputs in the allowed range. It sounds scary, but in practice it’s often very simple. For example: “If the parser successfully parsed a number, then converting there-and-back should not change the value.” Or: “A string normalizer must be idempotent: if you apply it twice, the result is the same as after one time.”
The most common beginner mistake is trying to make the invariant “too smart,” as if you were writing a math theorem for a dissertation. Don’t. We need properties that are simultaneously:
1) truly required by the function’s contract,
2) easy to check in code,
3) meaningful across a huge number of inputs.
To make it easier, here is a small table with examples of “healthy” invariants:
| Function type | Invariant example | Why it’s convenient |
|---|---|---|
| String normalization | |
No need to know the “ideal text” in advance; we check result stability |
| Parsing/formatting | |
Fuzzing will find weird representations by itself |
| Search/filtering | |
The simplest guarantee; helps catch “result inflation” |
| Validation | |
Catches unstable checks |
Next, we’ll take two very practical examples for an educational app.
2. Tasker: title normalization
To keep the examples concrete, let’s assume we have an educational application Tasker (a mini task manager). Its job is simple: store tasks, and let the user type titles however they want—but store a neat, normalized version.
For example, the user entered " buy milk ", and we want to store "buy milk". That is: trim edges and collapse consecutive whitespace characters.
Function NormalizeTitle
Let’s implement NormalizeTitle. It’s intentionally small: perfect for getting a feel for fuzzing without pain.
package tasker
import "strings"
func NormalizeTitle(s string) string {
parts := strings.Fields(s)
return strings.Join(parts, " ")
}
strings.Fields splits a string by any whitespace (spaces, tabs, newlines), and Join glues it back together with a single space. This yields a simple and fairly reliable normalization.
With a regular test, we could check a couple of cases: empty string, spaces, tabs, normal text. But a fuzz test lets you check this across thousands of variants, including “mush” you wouldn’t invent on purpose (and it saves you time and nerves).
Skeleton FuzzXxx(f *testing.F)
From a technical perspective, a fuzz test is a function in *_test.go that starts with Fuzz, takes *testing.F, and calls f.Fuzz(...) inside. In shape, it’s a bit closer to a “test runner” than a regular TestXxx.
First, the minimal skeleton (no invariant yet, just to see the shape):
package tasker
import "testing"
func FuzzNormalizeTitle(f *testing.F) {
f.Fuzz(func(t *testing.T, s string) {
_ = NormalizeTitle(s)
})
}
Even such a fuzz test is already useful, because it searches for the “most basic evil”—panics. If NormalizeTitle ever crashes on some input, fuzzing will try to find that input.
But we can (and should) add an invariant; otherwise this turns into “check that the program doesn’t crash”: useful, but too weak in meaning.
Seeds (f.Add) and idempotence
Now let’s make the fuzz test more meaningful. We’ll start with f.Add(...): these are starting examples that fuzzing uses as “seeds” (seed inputs). These are not test cases in the sense of “only they matter.” They’re more like starting points from which the generator mutates inputs.
We’ll pick a very practical invariant: normalization must be idempotent.
That is:
NormalizeTitle(s) == NormalizeTitle(NormalizeTitle(s))
Code:
package tasker
import "testing"
func FuzzNormalizeTitle(f *testing.F) {
f.Add("")
f.Add(" ")
f.Add("buy milk")
f.Fuzz(func(t *testing.T, s string) {
a := NormalizeTitle(s)
b := NormalizeTitle(a)
if a != b {
t.Fatalf("not idempotent: %q -> %q -> %q", s, a, b)
}
})
}
The key idea to internalize: fuzzing will feed you strings s (including weird ones), and you check that your “sanitizer” is stable. If it’s unstable, that’s a red flag: either the logic is wrong, or you accidentally introduced an effect that changes the string every time (for example, appending a random suffix, the current date, a counter—this happens too, especially in logging and ID generation).
3. Tasker: ID parsing and round-trip
Now let’s take an even more “classic” fuzzing case: a number parser. In Tasker, let tasks have an integer ID, and the user enters it in the CLI as a string. We want:
- ParseTaskID(" 42 ") to return 42;
- on garbage, return an error;
- on valid input, behave consistently.
Function ParseTaskID
Implementation:
package tasker
import (
"strconv"
"strings"
)
func ParseTaskID(s string) (int, error) {
s = strings.TrimSpace(s)
return strconv.Atoi(s)
}
Now the fuzz invariant. The classic one: if parsing succeeds, then the “there-and-back” conversion doesn’t change the value.
In other words:
- if n, err := ParseTaskID(s) and err == nil,
- then ParseTaskID(strconv.Itoa(n)) also succeeds and returns n.
Fuzz test:
package tasker
import (
"strconv"
"testing"
)
func FuzzParseTaskID(f *testing.F) {
f.Add("0")
f.Add("42")
f.Add("-7")
f.Fuzz(func(t *testing.T, s string) {
n, err := ParseTaskID(s)
if err != nil {
return
}
s2 := strconv.Itoa(n)
n2, err2 := ParseTaskID(s2)
if err2 != nil || n2 != n {
t.Fatalf("roundtrip failed: %q -> %d -> %q -> (%d, %v)", s, n, s2, n2, err2)
}
})
}
Notice the very Go-ish approach: if the input doesn’t fit (the parser returned an error), we simply exit with return. This is fine: fuzzing is not required to make all inputs valid. Our goal is to check the property on a valid subset of inputs, not to force the parser to accept everything.
How to run fuzzing and what you will see
Up to this point, we’ve been writing code that looks almost like a normal test. But it runs differently: fuzzing is a separate mode of running tests.
The basic command is:
go test -fuzz=FuzzNormalizeTitle
If you have many regular tests in the package and want to focus specifically on fuzzing, people often run it like this (because you already know -run):
go test -run=^$ -fuzz=FuzzNormalizeTitle
The meaning of -run=^$ is simple: “don’t run regular Test...,” because the regex ^$ matches no normal test name. This is not mandatory, but it sometimes speeds up iterations when you want to “run the fuzzer” separately.
To avoid getting lost, it helps to keep the process in your head as a diagram:
flowchart TD
A["seed inputs: f.Add(...)"] --> B[input variant generator]
B --> C[your function]
C --> D{does the invariant hold?}
D -->|yes| B
D -->|no / panic| E[save input + show report]
Your job as a developer is to choose an invariant and ensure that, when it’s violated, you get a clear message (t.Fatalf(...)); otherwise you’ll get “found a bug, but I don’t understand where”—which is almost the same as “didn’t find a bug,” just with extra suffering.
What to do if fuzzing found a problem
The nicest thing about fuzzing is not that it “tried a thousand strings.” The nicest thing is that it can find an input you wouldn’t think of even after your third cup of coffee, and save it so the bug becomes reproducible.
A typical scenario looks like this:
- you run go test -fuzz=FuzzParseTaskID;
- the fuzzer finds an input that causes a crash or violates the invariant;
- go test prints that input (and typically saves it for reproduction).
And here is an important engineering habit: turn the found input into a regression test. Because fuzzing is probabilistic: today it quickly found the bug, tomorrow you change the code, and the bug might only show up after 20 minutes. Better to lock it in.
The simplest approach a beginner needs to know: add the discovered string as another seed via f.Add("..."), and sometimes also as a separate regular Test... (if the input is short and clearly important). Then on the next fuzzing run, it will start from that case immediately.
Example: suppose the fuzzer found that the string "--1" leads to weird behavior (not required, but suppose). Then you lock it in:
func FuzzParseTaskID(f *testing.F) {
f.Add("--1") // discovered problematic case; now it’s with us forever
// ...
}
That’s “caught a bug → made it reproducible → won’t lose it again.”
4. Typical mistakes when working with fuzz tests
Mistake #1: treating fuzzing as a replacement for unit tests.
Fuzz tests are great at finding unexpected inputs and classes of crashes, but they do not replace regular tests with clear examples. Unit tests remain your “in-your-face contract”: readable, deterministic, explaining business rules. Fuzzing is more like a “crash test” and a check of general properties.
Mistake #2: writing an invariant that is not actually required to be true.
For example, for string normalization you might accidentally require that the result is always non-empty. But an empty task title may be a valid input that you handle separately. If the invariant is too “strong,” the fuzzer will honestly find “failures” that aren’t failures—they are your incorrect formulation.
Mistake #3: forgetting about preconditions and getting mad at parse errors.
In a parser fuzz test, it’s normal to write if err != nil { return }. If instead you do t.Fatalf on every error, you turn fuzzing into a pointless check “all strings must be numbers.” They don’t have to be. They must either parse or produce a neat error—that’s it.
Mistake #4: making the invariant non-deterministic.
If inside the check you use the current time, global counters, random numbers, or a dependency on map iteration order, fuzzing will start to “wobble”: the same input sometimes passes, sometimes fails. This is one of the most unpleasant kinds of bugs. The invariant must be reproducible: same input → same check result.
Mistake #5: trying to stuff “the whole system” into fuzzing.
Beginners really want: “Let’s fuzz the entire CLI!” In practice, fuzzing works best on small, clean functions: parsing, normalization, codecs, validation. The fewer side effects (files, network, environment), the more benefit and the fewer mysterious crashes “for no reason.”
GO TO FULL VERSION