Fuzz Testing in Golang
The on-rails way to test and build extremely safe code.
“Fuzz testing” is an automated method for testing code. Inputs are auto-generated and fed into your code to find errors.
Go added an inbuilt fuzzer in version 1.18. You provide initial valid inputs, known as a “seed corpus”. The fuzzer uses coverage guidance to find edge cases and issues in your code. The process is automatic, the inputs are saved and re-runnable, and the coverage is high.
Here’s how you can fuzz your code.
Step 0 — Find a function you want to test
The first thing you’ll need is a function. I presume you have one in mind, but if not, you can set up a dummy function to follow this tutorial. Start a new Go project with:
mkdir go_fuzzing
cd go_fuzzing
go mod init tutorial/fuzzing
This will add a go.mod file in your /go_fuzzing directory, setting up a module called tutorial/fuzzing. From here, you can add a new Golang file with:
touch some_function.go
Open the file in your IDE of choice and add the following function:
package main
import (
"fmt"
)
type Person struct {
Name string
Age int
Active bool
}
func mapToStruct(data map[string]any) (*Person, error) {
person := Person{
Name: data["name"].(string),
Age: data["age"].(int),
Active: data["active"].(bool),
}
// Perform some operations based on the values
if person.Active && person.Age < 1 {
fmt.Printf("Welcome, active user %s!\n", person.Name)
panic("babies can't be active")
} else if person.Active && person.Age > 100 {
fmt.Printf("Hello, %s. Please activate your account.\n", person.Name)
panic("you're too old to be active")
}
fmt.Printf("User %s is %d years old.\n", person.Name, person.Age)
return &person, nil
}
This function takes a map[string]any as input because this is how JSON is passed around in Golang. JSON is messy and changes frequently, so fuzzing is often used on JSON-derived inputs. I have added panic statements deliberately to cause unhandled errors for you to fix.
Step 1 — Write a Fuzzing function
Now you have a function; to fuzz it, you need to make a test file. If you’re on the terminal, from your root, you can run:
touch some_function_test.go
The file containing your fuzz function needs a name ending of _test so it’s picked up by go test. I usually end the function in _fuzz_test to separate fuzzing from unit tests, but that isn’t necessary.
If you copied my example function above, you can paste the following code into this file:
package main
import (
"encoding/json"
"testing"
)
func FuzzMapToStruct(f *testing.F) {
// The initial test cases used by the fuzzer to initiate the fuzzing process
// These help the fuzzer understand the input space of the function
seedCorpus := []map[string]any{
map[string]any{"name": "John", "age": 30, "active": true},
map[string]any{"name": "Jane", "age": 25, "active": false},
map[string]any{"name": "Jim", "age": 10, "active": true},
}
// Add the initial test cases to the fuzzer
for _, data := range seedCorpus {
// Convert the map to a JSON string, error on failure
jsonData, err := json.Marshal(data)
if err != nil {
f.Fatalf("failed to marshal data: %v", err)
}
// Add the JSON string to the fuzzer as a byte slice
f.Add([]byte(jsonData))
}
f.Fuzz(func(t *testing.T, input []byte) {
// Unmarshal the input into a map[string]any
var data map[string]any
err := json.Unmarshal(input, &data)
if err != nil {
t.Skip("failed to unmarshal data,skipping")
}
// Call the function with the unmarshalled data
// This function will panic for various reasons, and you must fix it.
mapToStruct(data)
})
}
This begins by setting up a seed corpus of acceptable values. The fuzzer will use this to generate new inputs. The fuzzing function is started by the f.Fuzz line and is provided input in the form of a byte array: []byte. If the input cannot be unmarshalled into map[string]any it will be skipped. This avoids any none JSON-like inputs from getting into the function.
Aside — Fuzzing Inputs
The fuzzing function accepts a limited number of types. These are:
string,[]byteint,int8,int16,int32/rune,int64uint,uint8/byte,uint16,uint32,uint64float32,float64bool
Step 2 — Run the Fuzzing function
Now you have your Fuzzing function set up, you can run the tests with:
go test -fuzz=YourFuzzFunctionNameHere
This will run your fuzzing tests until a panic or until you stop them. If you want to set the time limit up front, you can run:
go test -fuzz=YourFuzzFunctionNameHere -fuzztime=30s
Your options are:
- seconds:
s - minutes:
m - hours:
h
You can combine these together like this: 1h2m31s
Autogenerated Test Data
Every time your fuzzer finds a different code path through your function, it saves the input to $GOCACHE/fuzz. To see the inputs, visit your $GOCACHE folder. If you don’t know what it is, you can find it with:
go env GOCACHE
To visit the GOCACHE you can use:
cd <path-to-your-GOCACHE>/fuzz/
From here, if you list the directories in the file, you will see the names of the modules you’ve fuzzed. The directory structure matches the structure of the module. For example, if you have fuzzed a function held in:
<your-module-root>/backend/api/<your-function-here>
The autogenerated data will follow the same path to reach your data.
Autogenerated Data That Causes Errors
If an error occurs, the fuzzer will attempt to find the minimum failing case and then save it as a regression test under:
testdata/fuzz/<your-fuzzing-function-name-here>
This data can help you establish the use cases you must deal with. If you want to run just your failing autogenerated tests, use:
go test -v -run=<your-fuzzing-function-name-here>
The -v flag will give you verbose details, and using go test without the -fuzz flag will run the failing autogenerated tests only.
Removing Autogenerated Data
If you make substantial changes to the data format of your seed corpus, you may invalidate past fuzz tests. This will result in output like this when you attempt to fuzz again:
=== RUN YourFuzzTestHere
fuzz: elapsed: 1s, gathering baseline coverage: 0/729 completed
failure while testing seed corpus entry: YourFuzzTestHere/seed#0
fuzz: elapsed: 3s, gathering baseline coverage: 0/729 completed
your_fuzz_test.go:184: Fuzzing complete
--- FAIL: YourFuzzTestHere (3.45s)
--- FAIL: YourFuzzTestHere (0.00s)
testing.go:1591: panic: ...(some issue)...
To avoid this, you have to delete your autogenerated data, which you can do by deleting the following directories:
rm -r <path-to-your-GOCACHE>/fuzz/<path-to-your-fuzzFunction>
rm -r testdata/fuzz/<your-fuzzing-function-name-here>
This will remove all the previous tests and let you run from a clean slate after major changes.
Avoid Running Non-Fuzz Tests
You may have noticed that the -fuzz flag doesn’t prevent all the regular tests from being run first. This is nice for oversight but annoying during debugging. To prevent them from being printed, run:
go test -run=^$ -fuzz=FuzzNormaliseCDR
The -run=^$ flag tells go test to run tests that match an empty string, i.e. none. ^ in regex is the start of a string, and $ is the end. ^$ means the start is the end.
Step 3 — Fix issues
If you’re fuzzing my example function, you will encounter many issues. As an exercise, try to fix all of these and get the fuzzer running for five minutes or more. Then, report back in the comments. How many issues did you find? How many autogenerated test cases do you have?
If you’re working on your own function, good luck, have fun! It’s fascinating to see what edge cases you haven’t considered.
Extras
Here is the official Go Tutorial for fuzzing:
Tutorial: Getting started with fuzzing - The Go Programming Language
and here is the official Go fuzzing overview:
Go Fuzzing - The Go Programming Language
Final Words
Thank you for reading. If you have any questions, drop them in the comments. I’m releasing a further article on “extreme fuzzing” to show you how to fuzz like a pro.
If you love the idea of fuzzing your code, follow this publication to read it next. Happy fuzzing!