Benchmarks, Fuzzing and synctest
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18. It covers the three parts of the testing
package that go beyond plain unit tests: benchmarks (BenchmarkXxx(b *testing.B)), fuzz tests
(FuzzXxx(f *testing.F)) and the testing/synctest package for testing concurrent and time-dependent code without
real waiting. The basics of go test, _test.go files, subtests and table-driven tests are on
Testing; this page assumes them. For finding out why a benchmark is slow
see Profiling, Tracing and Diagnostics and
Performance and PGO. The equivalent features in Rust are on
Testing and Benchmarking.
All examples use the shelf scenario (module example.com/shelf, package catalog):
module example.com/shelf
go 1.27
Every listing was compiled, vetted with go vet and run on Go 1.27.2 (darwin/arm64, ten cores). Timings are
machine-specific: the ns/op figures below show the shape of the output and rough orders of magnitude, not numbers you
will reproduce. Where a result is meant to be reproducible the page uses allocation counts (allocs/op), fixed
iteration counts (-benchtime=Nx) and fake time, none of which depend on the machine.
The three tools at a glance
| Tool | Entry point | Run with | Answers the question |
|---|---|---|---|
Benchmark |
|
|
How fast is it, and how much does it allocate? |
Fuzz test |
|
|
Does it hold for inputs I did not think of? |
synctest bubble |
|
|
Does my concurrent or timer-driven code behave, without sleeping for real? |
All three live in _test.go files, are discovered by name (Benchmark, Fuzz or Test followed by a name that does
not start with a lower-case letter) and are listed together by go test -list:
go test -list '.*' ./catalog
TestHoldExpires
TestPoll
TestWaitOrdering
BenchmarkParseISBN
BenchmarkParseISBNOld
BenchmarkParseISBNBad
BenchmarkStoreAll
BenchmarkParallelGet
FuzzParseISBN
FuzzInitials
FuzzTitleBytes
ok example.com/shelf/catalog 0.072s
Source: cmd/go: Testing flags
A test function whose signature is wrong is reported by go vet (its tests analyzer):
b_test.go:7:1: wrong signature for BenchmarkMissing, must be: func BenchmarkMissing(b *testing.B)
Source: x/tools: the tests analyzer
Benchmarks
A benchmark runs a piece of code repeatedly and reports the average time per operation. go test does not run
benchmarks unless -bench is given, and the usual advice is to also pass -run '^$' so the unit tests are skipped
while measuring.
The shape of a benchmark: b.Loop
Since Go 1.24 the idiomatic benchmark body is for b.Loop() { ... }. The same code with the older for i := 0; i < b.N; i++
form is shown beside it:
package catalog
import (
"fmt"
"testing"
)
func BenchmarkParseISBN(b *testing.B) {
for b.Loop() {
if _, err := ParseISBN("978-0-13-468599-1"); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkParseISBNOld(b *testing.B) {
for i := 0; i < b.N; i++ {
if _, err := ParseISBN("978-0-13-468599-1"); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkParseISBNBad(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
_, _ = ParseISBN("978-0-13-468599-2")
}
}
func BenchmarkStoreAll(b *testing.B) {
for _, n := range []int{10, 100, 1000} {
b.Run(fmt.Sprintf("books=%d", n), func(b *testing.B) {
s := NewMemStore()
for i := range n {
s.Put(Book{ISBN: ISBN(fmt.Sprint(i)), Title: "t"})
}
b.ReportAllocs()
for b.Loop() {
_ = s.All()
}
})
}
}
func BenchmarkParallelGet(b *testing.B) {
s := NewMemStore()
s.Put(Book{ISBN: "9780134685991", Title: "t"})
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, _ = s.Get("9780134685991")
}
})
}
Source: testing: B.Loop
go test -run '^$' -bench 'ParseISBN' -benchmem ./catalog
goos: darwin
goarch: arm64
pkg: example.com/shelf/catalog
cpu: Apple M5
BenchmarkParseISBN-10 22046788 54.68 ns/op 16 B/op 1 allocs/op
BenchmarkParseISBNOld-10 21901906 55.86 ns/op 16 B/op 1 allocs/op
BenchmarkParseISBNBad-10 6876934 176.9 ns/op 112 B/op 4 allocs/op
PASS
ok example.com/shelf/catalog 3.901s
Source: testing: Benchmarks
Reading a result line:
-
BenchmarkParseISBN-10is the benchmark name; the-10suffix isGOMAXPROCSfor the run. WithGOMAXPROCSequal to 1 the suffix is omitted. -
the first number (
22046788) is how many iterations the final run executed (the value ofb.Nafter the loop); -
54.68 ns/opis nanoseconds per iteration, derived from total time divided by iterations; -
B/opandallocs/op(shown by-benchmemorb.ReportAllocs()) are heap bytes and heap allocations per iteration.
The allocation columns are the trustworthy part: 16 B/op and 1 allocs/op for the successful parse, and 112 B/op,
4 allocs/op for the failing parse (which builds an error with fmt.Errorf, wrapping with %w and quoting the input),
will be the same on your machine, while the nanoseconds will not. Allocation counts are the first thing to look at when
tuning, and they make a stable assertion in a regression test (see testing.AllocsPerRun below).
Why b.Loop replaces the b.N loop
The two forms of the benchmark above produce comparable numbers, so why did Go 1.24 add b.Loop? The release notes give
two reasons.
-
Setup runs once. The benchmark function is called by the framework several times, with a growing
b.N, to find an iteration count that runs for about a second. With ab.Nloop everything before the loop runs on every call; withb.Loopthe function body runs exactly once per-count, so expensive setup and cleanup execute once and are not timed. The framework starts the timer at the first call tob.Loopand stops it when it returnsfalse. -
The compiler cannot optimise the work away. Inside
for b.Loop() { ... }the arguments and results of calls and assigned variables are kept alive, so a call whose result is unused is not deleted as dead code. The condition has to be written exactlyb.Loop()for this to apply.
The first point is visible by logging from inside the benchmark. -benchtime=3000x makes the framework do a calibration
call with b.N == 1 and then one run with b.N == 3000:
package catalog
import "testing"
func BenchmarkSetupCallsN(b *testing.B) {
b.Logf("setup runs, b.N = %d", b.N)
for i := 0; i < b.N; i++ {
_, _ = ParseISBN("9780134685991")
}
}
func BenchmarkSetupCallsLoop(b *testing.B) {
b.Logf("setup runs, b.N = %d", b.N)
for b.Loop() {
_, _ = ParseISBN("9780134685991")
}
}
goos: darwin
goarch: arm64
pkg: example.com/shelf/catalog
cpu: Apple M5
BenchmarkSetupCallsN-10 3000 23.90 ns/op
--- BENCH: BenchmarkSetupCallsN-10
calls_test.go:6: setup runs, b.N = 1
calls_test.go:6: setup runs, b.N = 3000
BenchmarkSetupCallsLoop-10 3000 22.35 ns/op
--- BENCH: BenchmarkSetupCallsLoop-10
calls_test.go:13: setup runs, b.N = 1
PASS
ok example.com/shelf/catalog 0.087s
The b.N version ran its setup code twice (and with the default one-second target it runs it several times, because
the framework keeps growing b.N: a 100 ms run logged it six times); the b.Loop version ran it once. If the setup is "build a catalogue of 100,000
books", that difference is the difference between a benchmark that starts instantly and one that does the setup over and
over.
A benchmark that has to run its body from several goroutines uses b.RunParallel (below) instead. Everywhere else write
new benchmarks with b.Loop. The b.N form remains valid and is what old code, and both books, use; go fix modernizers can rewrite
the simple cases (see
Formatting, vet, fix and Static Analysis).
Timer and allocation control
The timer methods exist for the b.N style and for the unusual cases of b.Loop.
| Method | Purpose |
|---|---|
|
Zero the elapsed time, allocation counters and custom metrics; call after expensive setup in a |
|
Pause and resume timing around per-iteration work you do not want measured. Calling them inside the loop makes the framework itself dominate; prefer restructuring. |
|
Report |
|
Declare that one iteration processes |
|
Add a custom metric, for example |
|
Time measured so far, for computing custom metrics. |
|
Sub-benchmark (below). |
|
Run the body in |
|
Same helpers as on |
|
Report problems and skip, as in tests. |
Source: testing: type B
testing.AllocsPerRun(runs, f) is not a benchmark, but it is the deterministic companion to -benchmem: it returns the
average number of allocations of f as a float64, and can sit in an ordinary TestXxx that fails if a hot path starts
allocating.
Source: testing: AllocsPerRun
Flags for running benchmarks
| Flag | Meaning |
|---|---|
|
Run the benchmarks that match. |
|
Print |
|
How long to run each benchmark; a duration ( |
|
Run each benchmark (and test) |
|
Run with each |
|
Which tests to run; |
|
Write a profile or execution trace of the run. |
|
Abort the whole test binary after |
|
List matching tests, benchmarks, fuzz tests and examples without running them. |
Source: cmd/go: Testing flags
-benchtime with an iteration count
-benchtime=Nx runs the benchmark with b.N exactly N. It skips the calibration phase that makes the default run last
several seconds, and it makes two runs comparable on work done rather than on time budget. It is the right choice for a
quick smoke check in CI and for the examples on this page:
go test -run '^$' -bench 'ParseISBN$' -benchtime=1000x -benchmem ./catalog
goos: darwin
goarch: arm64
pkg: example.com/shelf/catalog
cpu: Apple M5
BenchmarkParseISBN-10 1000 120.8 ns/op 16 B/op 1 allocs/op
PASS
ok example.com/shelf/catalog 0.090s
Source: cmd/go: Testing flags
The reported ns/op is higher than the earlier run (about 120 ns instead of about 55 ns): with only 1000 iterations the
CPU has not reached its steady state and startup effects are a large fraction of the total. Short fixed counts are good for
checking that a benchmark works and for allocation figures, not for timing.
-count and statistics
A single run is a single sample, and the samples above already differ by a factor of two:
go test -run '^$' -bench 'ParseISBN$' -benchtime=1000x -count=3 ./catalog
goos: darwin
goarch: arm64
pkg: example.com/shelf/catalog
cpu: Apple M5
BenchmarkParseISBN-10 1000 126.5 ns/op
BenchmarkParseISBN-10 1000 83.33 ns/op
BenchmarkParseISBN-10 1000 74.79 ns/op
PASS
ok example.com/shelf/catalog 0.091s
Source: cmd/go: Testing flags
For decisions, run each variant ten or more times (-count=10), on a quiet machine, and compare the two sets of samples
with a statistical tool (see benchstat below) instead of comparing two single numbers by eye.
Sub-benchmarks
b.Run(name, f) creates a sub-benchmark with its own b.N, exactly like t.Run for tests. A loop over input sizes is
the standard way to see how cost grows. The StoreAll benchmark in the file above builds a store of 10, 100 and 1000
books and measures All():
go test -run '^$' -bench 'StoreAll' -benchtime=2000x -benchmem ./catalog
goos: darwin
goarch: arm64
pkg: example.com/shelf/catalog
cpu: Apple M5
BenchmarkStoreAll/books=10-10 2000 184.1 ns/op 576 B/op 1 allocs/op
BenchmarkStoreAll/books=100-10 2000 1185 ns/op 6144 B/op 1 allocs/op
BenchmarkStoreAll/books=1000-10 2000 11111 ns/op 57347 B/op 1 allocs/op
PASS
ok example.com/shelf/catalog 0.115s
Two things here are deterministic and worth reading: 1 allocs/op for every size (one slice is allocated, sized with
make([]Book, 0, len(m.books))), and B/op growing linearly with the number of books (576, 6144 and about 57000 bytes
for 10, 100 and 1000 books; a Book is 56 bytes on a 64-bit platform, which fits the 576-byte size class for ten). Only
one level is shown; names with / select a sub-benchmark in -bench, one regexp element per level:
go test -run '^$' -bench 'StoreAll/books=100$' -benchtime=2000x ./catalog
goos: darwin
goarch: arm64
pkg: example.com/shelf/catalog
cpu: Apple M5
BenchmarkStoreAll/books=100-10 2000 1222 ns/op 6144 B/op 1 allocs/op
PASS
ok example.com/shelf/catalog 0.090s
Source: cmd/go: Testing flags
RunParallel and -cpu
b.RunParallel runs the body in GOMAXPROCS goroutines (more with b.SetParallelism), each iterating with pb.Next().
It measures a code path under contention, such as the RWMutex-guarded MemStore.Get. -cpu repeats the benchmark with
different GOMAXPROCS values; the first run has no -N suffix because GOMAXPROCS is 1:
go test -run '^$' -bench 'ParallelGet' -benchtime=100000x -cpu 1,2,4 ./catalog
goos: darwin
goarch: arm64
pkg: example.com/shelf/catalog
cpu: Apple M5
BenchmarkParallelGet 100000 12.17 ns/op
BenchmarkParallelGet-2 100000 52.67 ns/op
BenchmarkParallelGet-4 100000 64.10 ns/op
PASS
ok example.com/shelf/catalog 0.100s
Source: testing: B.RunParallel
ns/op in a parallel benchmark is wall time divided by the total iterations across all goroutines, so a perfectly scaling
function would get faster (smaller) with more CPUs. Here it gets slower: every RLock and RUnlock writes the same
cache line of the mutex, so more goroutines mean more cache-line transfers. The shape (flat or slower, not faster) is the
interesting result, and the mutex and sync/atomic costs behind it are on
Sync and Atomics.
Writing benchmarks that measure what you think
-
Keep the compared inputs identical and the result observable.
b.Loophandles dead-code elimination for calls inside its braces. In ab.Nloop assign the result to a package-level variable (sink = f(x)) if the compiler could otherwise remove the call. -
Do not benchmark and log:
b.Logandfmt.Printlninside the loop measure the output, not your code. -
Report allocations. A speed-up that removes an allocation is usually more robust than one that shaves cycles.
-
A benchmark that stays noisy on
-count=10is measuring the machine. Close other work, disable power saving, pin to a quiet host, and prefer-cpu 1unless concurrency is the point. -
Benchmark runs are never served from the test cache, because
-benchis not one of the cacheable flags (-benchtime,-cpu,-run,-short,-timeout,-vand a few others are). A plaingo testrun of the same package can be cached (see Testing);-count=1is the idiomatic way to disable that.
Comparing results: benchstat
benchstat is the standard tool for comparing two sets of -count samples. It is not part of the Go distribution; it is
the command golang.org/x/perf/cmd/benchstat, installed with go install golang.org/x/perf/cmd/benchstat@latest.
It is not installed on the machine used to prepare this page and was not run; what follows describes it from its
documentation.
# Not run here (benchstat is not installed): the usual workflow.
go test -run '^$' -bench . -benchmem -count=10 ./catalog > old.txt
# ...change the code...
go test -run '^$' -bench . -benchmem -count=10 ./catalog > new.txt
benchstat old.txt new.txt
benchstat parses the Go benchmark output format, groups the samples by benchmark name and metric (sec/op, B/op,
allocs/op), and prints for each the mean with a confidence interval and the relative change between files. Where the
difference is not statistically significant it prints ~ instead of a percentage and the p-value, which stops a
reader from "seeing" a speed-up that is only noise. Use at least ten samples per side; with fewer it cannot tell.
Source: golang.org/x/perf/cmd/benchstat
Fuzzing
Fuzzing (introduced in Go 1.18) is coverage-guided random testing built into go test. You write the property that
must hold for any input, give a few example inputs, and the toolchain mutates them, keeps the mutants that reach new
code, and stops as soon as the property fails. Every failure is saved as a file so it becomes a permanent regression
test. It complements table-driven tests (inputs you thought of) with inputs you did not.
Anatomy of a fuzz test
package catalog
import (
"errors"
"testing"
"unicode/utf8"
)
func FuzzParseISBN(f *testing.F) {
f.Add("978-0-13-468599-1")
f.Add("9780134685991")
f.Add("")
f.Add("978-0-13-468599-2")
f.Fuzz(func(t *testing.T, s string) {
isbn, err := ParseISBN(s)
if err != nil {
if !errors.Is(err, ErrInvalidISBN) {
t.Fatalf("ParseISBN(%q) error %v does not wrap ErrInvalidISBN", s, err)
}
return
}
if len(isbn) != 13 {
t.Fatalf("ParseISBN(%q) = %q, want 13 digits", s, isbn)
}
again, err := ParseISBN(isbn.String())
if err != nil || again != isbn {
t.Fatalf("round trip of %q gave %q, %v", isbn, again, err)
}
})
}
func FuzzInitials(f *testing.F) {
f.Add("Alan Donovan")
f.Add("Brian Kernighan")
f.Fuzz(func(t *testing.T, name string) {
got := Initials(name)
if !utf8.ValidString(got) {
t.Errorf("Initials(%q) = %q, which is not valid UTF-8", name, got)
}
})
}
Source: Go Fuzzing: Writing fuzz tests
The rules:
-
the function is named
FuzzXxx, takes only*testing.F, and lives in a_test.gofile; -
f.Add(args...)adds a seed corpus entry; the arguments must match, in order and exact type, the parameters of the fuzz target that follows the*testing.Tparameter; -
f.Fuzz(func(t *testing.T, args...) { ... })is called exactly once and registers the fuzz target; the arguments (aftert) are the fuzzed inputs; -
permitted argument types are
string,[]byte,bool,float32,float64, and all integer types (int,int8toint64,uint,uint8touint64);runeandbyteare aliases of permitted types. Nothing else compiles: a struct parameter is rejected bygo vetand the build (shown below); -
the target must be deterministic and not depend on global state or on other inputs, because the engine runs it in many worker processes at once and replays failures;
-
the target should be fast (microseconds), since the fuzzer’s value comes from executing millions of inputs.
Two properties are checked above. For FuzzParseISBN the property is an invariant of the API: whatever string is given,
the error wraps ErrInvalidISBN, and a success yields 13 digits that parse back to themselves. For FuzzInitials the
property is "the result is valid UTF-8".
The permitted types are checked at compile time:
package bad
import "testing"
type pair struct{ a, b int }
func FuzzBad(f *testing.F) {
f.Add(1)
f.Fuzz(func(t *testing.T, p pair) {})
}
./bad_test.go:9:30: fuzzing arguments can only have the following types: string, bool, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, []byte
FAIL example.com/shelf/bad2 [build failed]
Source: testing: F.Fuzz
What a plain go test does with a fuzz test
Without -fuzz, go test treats a fuzz test as an ordinary test over its corpus: the f.Add seeds, and every file in
testdata/fuzz/FuzzXxx/. Each entry is a subtest named seed#N or by file name. This is what runs in normal CI:
go test -run 'FuzzInitials|FuzzParseISBN' -v ./catalog
=== RUN FuzzParseISBN
=== RUN FuzzParseISBN/seed#0
=== RUN FuzzParseISBN/seed#1
=== RUN FuzzParseISBN/seed#2
=== RUN FuzzParseISBN/seed#3
--- PASS: FuzzParseISBN (0.00s)
--- PASS: FuzzParseISBN/seed#0 (0.00s)
--- PASS: FuzzParseISBN/seed#1 (0.00s)
--- PASS: FuzzParseISBN/seed#2 (0.00s)
--- PASS: FuzzParseISBN/seed#3 (0.00s)
=== RUN FuzzInitials
=== RUN FuzzInitials/seed#0
=== RUN FuzzInitials/seed#1
--- PASS: FuzzInitials (0.00s)
--- PASS: FuzzInitials/seed#0 (0.00s)
--- PASS: FuzzInitials/seed#1 (0.00s)
PASS
ok example.com/shelf/catalog 0.202s
Source: testing: Fuzzing
The deliberately buggy Initials below passes both of its seeds, which is the point of fuzzing: the seeds are ordinary
names and the bug is not in them.
Finding a real failure
Initials should return the upper-case first letter of each word of an author’s name:
package catalog
import "strings"
// Initials returns the upper-case initials of an author name.
func Initials(name string) string {
var sb strings.Builder
for _, w := range strings.Fields(name) {
sb.WriteString(strings.ToUpper(w)[:1])
}
return sb.String()
}
The bug: strings.ToUpper(w)[:1] slices one byte, not one character. For an ASCII name that is the first letter; for a
name that starts with a multi-byte character it cuts that character in half and returns an invalid UTF-8 byte (see
Strings, Runes and UTF-8). The fuzzer is started with -fuzz
(an unanchored regexp that must match exactly one fuzz test) and a time limit:
go test -run '^$' -fuzz '^FuzzInitials$' -fuzztime=10s ./catalog
fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers
fuzz: minimizing 42-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzInitials (0.02s)
--- FAIL: FuzzInitials (0.00s)
fuzz_test.go:38: Initials("\x91") = "\xef", which is not valid UTF-8
Failing input written to testdata/fuzz/FuzzInitials/1ffc28f7538e29d7
To re-run:
go test -run=FuzzInitials/1ffc28f7538e29d7
FAIL
exit status 1
FAIL example.com/shelf/catalog 0.227s
Source: Go Fuzzing: Running fuzz tests
Reading the output:
-
gathering baseline coverageruns every seed and corpus entry once to record which code they reach; -
now fuzzing with 10 workersmeans ten worker processes (the-paralleldefault isGOMAXPROCS); -
the engine found a crashing input within the first moments, then minimized it (
minimizing 42-byte failing input file): it keeps removing and simplifying bytes while the target still fails, bounded by-fuzzminimizetime(default60s; this very short run needed only a moment). The minimal input here is the single byte\x91, which is invalid UTF-8 on its own; names that start with a valid multi-byte character such as an accented capital fail the same way; -
the failing input was written to
testdata/fuzz/FuzzInitials/under a name derived from the content hash, and the output prints the command to re-run just that entry.
The run was fast because the property is violated by very simple inputs; for deeper bugs the engine can run for minutes
or hours (the default -fuzztime is forever), printing a status line every few seconds with execs, executions per
second and new interesting (inputs that reached new code).
The corpus file format and testdata/fuzz
ls catalog/testdata/fuzz/FuzzInitials
cat catalog/testdata/fuzz/FuzzInitials/*
1ffc28f7538e29d7
go test fuzz v1
string("\x91")
The file is plain text:
-
the first line is the version header
go test fuzz v1; -
each following line is one argument of the fuzz target, in order, written as a Go-syntax typed value:
string("..."),[]byte("..."),int64(572293),bool(true),float64(1.5),uint8(7),rune('x')and so on, with Go string escapes (\x91above) for non-printable bytes. The lines can be copied into Go code, for instance as arguments tof.Add.
A file can be written by hand to add a regression case or a seed; a target with four parameters needs four lines:
go test fuzz v1
string("Zen and the Art")
int(-7)
[]byte("ab\x00")
bool(false)
package catalog
import "testing"
func FuzzTitleBytes(f *testing.F) {
f.Add("Go", 1, []byte("x"), true)
f.Fuzz(func(t *testing.T, title string, n int, extra []byte, strict bool) {
t.Logf("title=%q n=%d extra=%v strict=%t", title, n, extra, strict)
})
}
=== RUN FuzzTitleBytes
=== RUN FuzzTitleBytes/seed#0
trunc_test.go:8: title="Go" n=1 extra=[120] strict=true
=== RUN FuzzTitleBytes/hand-written
trunc_test.go:8: title="Zen and the Art" n=-7 extra=[97 98 0] strict=false
--- PASS: FuzzTitleBytes (0.00s)
--- PASS: FuzzTitleBytes/seed#0 (0.00s)
--- PASS: FuzzTitleBytes/hand-written (0.00s)
PASS
ok example.com/shelf/catalog 0.070s
Source: Go Fuzzing: Corpus file format
Where the files live:
| Location | Role |
|---|---|
|
The seed corpus in code. |
|
The checked-in corpus: failures written by the fuzzer plus any hand-added files. |
|
The generated corpus: the "interesting" inputs the fuzzer has accumulated. It is reused by later |
Source: Go Fuzzing: Glossary
The fuzzing loop
Fixing the bug and keeping the regression test
The correct implementation decodes a rune instead of slicing a byte:
package catalog
import (
"strings"
"unicode/utf8"
)
// Initials returns the upper-case initials of an author name.
func Initials(name string) string {
var sb strings.Builder
for _, w := range strings.Fields(name) {
r, _ := utf8.DecodeRuneInString(w)
sb.WriteString(strings.ToUpper(string(r)))
}
return sb.String()
}
The file the fuzzer wrote is now an ordinary corpus entry. It is replayed by go test forever, and it passes:
=== RUN FuzzInitials
=== RUN FuzzInitials/seed#0
=== RUN FuzzInitials/seed#1
=== RUN FuzzInitials/1ffc28f7538e29d7
--- PASS: FuzzInitials (0.00s)
--- PASS: FuzzInitials/seed#0 (0.00s)
--- PASS: FuzzInitials/seed#1 (0.00s)
--- PASS: FuzzInitials/1ffc28f7538e29d7 (0.00s)
PASS
ok example.com/shelf/catalog 0.082s
go test -run 'FuzzInitials/1ffc28f7538e29d7' -v ./catalog
The same command, with the name the fuzzer printed, re-runs a single entry; before the fix it printed
Initials("\x91") = "\xef", which is not valid UTF-8 and failed.
Running fuzzers: flags and limits
| Flag | Meaning |
|---|---|
|
Fuzz the single fuzz test that matches. It must match exactly one fuzz test in exactly one package. |
|
Total time ( |
|
Budget for each minimization attempt; default |
|
Maximum concurrent worker processes while fuzzing (default |
|
Which tests run first; |
|
Do not apply to the fuzz test selected by |
Source: cmd/go: Testing flags
An execution budget makes a run reproducible in length; here the generated corpus left behind by the earlier runs was reloaded (the 76 baseline entries), and the fuzzer ran exactly 2000 more executions:
go test -run '^$' -fuzz '^FuzzParseISBN$' -fuzztime=2000x ./catalog
fuzz: elapsed: 0s, gathering baseline coverage: 0/76 completed
fuzz: elapsed: 0s, gathering baseline coverage: 76/76 completed, now fuzzing with 10 workers
fuzz: elapsed: 0s, execs: 2011 (63437/sec), new interesting: 2 (total: 78)
PASS
ok example.com/shelf/catalog 0.102s
Source: Go Fuzzing: Command line output
Naming more than one fuzz test is an error, as is naming several packages:
testing: will not fuzz, -fuzz matches more than one fuzz test: [FuzzParseISBN FuzzInitials FuzzTitleBytes]
FAIL
exit status 1
FAIL example.com/shelf/catalog 0.073s
Limitations to know about: the official guide says fuzzing should be run on a platform with coverage instrumentation (currently AMD64 and ARM64), otherwise the corpus cannot meaningfully grow; the engine uses substantial memory and CPU, since each worker is a separate process; a fuzz target that is slow, flaky or stateful starves the search; and fuzzing finds crashes and property violations, not "wrong but plausible" answers, so the property you write is the test.
What makes a good fuzz target
-
Round trips:
Decode(Encode(x)) == x,Parse(s.String()) == s. The ISBN example does this. -
Differential checks against a simple reference implementation.
-
Invariants of the output: sorted, valid UTF-8, within bounds, no
NaN. -
"Does not panic" for any input of a parser; errors are fine, panics are bugs.
-
Avoid
t.Skipas a filter that rejects most inputs; the engine wastes time on skipped values. Prefer to construct a valid value from the raw ones.
On a CI server, run the corpus with plain go test on every change, and run short -fuzz jobs (minutes) on a schedule or
with a dedicated fuzzing service. A failing input found there is committed to testdata/fuzz like any other test.
testing/synctest
Concurrent code and code that waits for time is hard to test: a test that sleeps for the real duration is slow, and one
that sleeps for a guessed duration is flaky. testing/synctest solves both by running the code in a bubble, an isolated
set of goroutines with a fake clock. It was an experiment in Go 1.24 (GOEXPERIMENT=synctest, with a different
synctest.Run API, announced for removal in 1.26), became generally available in Go 1.25 with synctest.Test and synctest.Wait,
and gained the synctest.Sleep helper in Go 1.27. The old synctest.Run is not present in Go 1.27.2’s go doc output. Go 1.27 also added httptest.NewTestServer, an HTTP test server on an
in-memory fake network meant to be used inside a bubble.
The package has exactly three functions:
func Test(t *testing.T, f func(*testing.T))
func Wait()
func Sleep(d time.Duration) // Go 1.27
Source: testing/synctest
The rules of a bubble
-
synctest.Test(t, f)runsfin a new bubble and returns when every goroutine started in it has exited. It must not be called from inside a bubble. The*testing.Tpassed tofis the bubble’s:Cleanupfunctions run inside the bubble just beforeTestreturns,t.Context()has aDonechannel associated with the bubble, andt.Run,t.Parallelandt.Deadlinemust not be called. -
Goroutines started with
goinside the bubble belong to it. -
Inside the bubble the
timepackage uses a fake clock. The initial time is midnight UTC on 2000-01-01. Time only advances when every goroutine in the bubble is durably blocked, and then it jumps straight to the next instant at which something can proceed (the next timer, sleep or deadline). -
A goroutine is durably blocked when it can be woken only by another goroutine of the same bubble. These operations block durably: a blocking send or receive on a channel created in the bubble; a
selectwhere every case is a channel created in the bubble;sync.Cond.Wait;sync.WaitGroup.WaitwhenAddwas called within the bubble;time.Sleep. -
These do not block durably, because something outside the bubble could wake them: locking a
sync.Mutexorsync.RWMutex, I/O such as a network read, and system calls. A goroutine stuck in one prevents the bubble’s clock from advancing. -
synctest.Wait()blocks until all other goroutines of the bubble are durably blocked. It is the way to say "let everything that can run, run". -
If every goroutine is durably blocked, no time can advance and
Waithas not been called, the bubble is deadlocked andTestpanics. -
A channel, timer or ticker created in a bubble is associated with it; operating on it from outside the bubble panics. A
sync.WaitGroupbecomes associated with the bubble on its firstAddorGo;Cond.Waitis durably blocking. -
Cleanup functions and finalizers registered with
runtime.AddCleanupandruntime.SetFinalizerrun outside any bubble.
Source: testing/synctest: Blocking
Testing a timer: the 48-hour hold
A hold reserves a book for a limited time and releases it with time.AfterFunc. The code under test, using only the
real time API:
package catalog
import (
"context"
"sync"
"time"
)
// Holds keeps books reserved for a limited time.
type Holds struct {
mu sync.Mutex
held map[ISBN]bool
}
// NewHolds returns an empty Holds.
func NewHolds() *Holds { return &Holds{held: map[ISBN]bool{}} }
// Place reserves isbn for ttl; the hold is released automatically.
func (h *Holds) Place(isbn ISBN, ttl time.Duration) {
h.mu.Lock()
h.held[isbn] = true
h.mu.Unlock()
time.AfterFunc(ttl, func() {
h.mu.Lock()
delete(h.held, isbn)
h.mu.Unlock()
})
}
// Held reports whether isbn is currently reserved.
func (h *Holds) Held(isbn ISBN) bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.held[isbn]
}
// Poll calls check every interval until ctx is cancelled and
// reports each result on the returned channel.
func Poll(ctx context.Context, interval time.Duration, check func() int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
select {
case out <- check():
case <-ctx.Done():
return
}
}
}
}()
return out
}
Testing the expiry with real time means waiting 48 hours or making the TTL injectable. Inside a bubble the test is the natural one:
package catalog
import (
"context"
"sync/atomic"
"testing"
"testing/synctest"
"time"
)
func TestHoldExpires(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
h := NewHolds()
start := time.Now()
h.Place("9780134685991", 48*time.Hour)
time.Sleep(47 * time.Hour)
synctest.Wait()
if !h.Held("9780134685991") {
t.Fatal("hold released too early")
}
time.Sleep(time.Hour)
synctest.Wait()
if h.Held("9780134685991") {
t.Fatal("hold not released after 48h")
}
t.Logf("start=%s elapsed=%s", start.UTC().Format(time.RFC3339), time.Since(start))
})
}
func TestPoll(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
n := 0
out := Poll(ctx, time.Minute, func() int { n++; return n })
for want := 1; want <= 3; want++ {
if got := <-out; got != want {
t.Fatalf("got %d, want %d", got, want)
}
t.Logf("tick %d at %s", want, time.Since(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)))
}
cancel()
if _, ok := <-out; ok {
t.Fatal("channel still open after cancel")
}
})
}
func TestWaitOrdering(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var done atomic.Bool
go func() {
time.Sleep(time.Second)
done.Store(true)
}()
synctest.Wait()
t.Logf("after Wait: done=%t, clock advanced %s", done.Load(), time.Since(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)))
synctest.Sleep(time.Second)
t.Logf("after Sleep: done=%t, clock advanced %s", done.Load(), time.Since(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)))
})
}
go test -run 'Hold|Poll|WaitOrdering' -v -race ./catalog
=== RUN TestHoldExpires
synctest_test.go:28: start=2000-01-01T00:00:00Z elapsed=48h0m0s
--- PASS: TestHoldExpires (0.00s)
=== RUN TestPoll
synctest_test.go:42: tick 1 at 1m0s
synctest_test.go:42: tick 2 at 2m0s
synctest_test.go:42: tick 3 at 3m0s
--- PASS: TestPoll (0.00s)
=== RUN TestWaitOrdering
synctest_test.go:59: after Wait: done=false, clock advanced 0s
synctest_test.go:61: after Sleep: done=true, clock advanced 1s
--- PASS: TestWaitOrdering (0.00s)
PASS
ok example.com/shelf/catalog 1.080s
What happened in TestHoldExpires: time.Now() returned midnight on 2000-01-01 UTC; the two time.Sleep calls (47 hours
and one hour) took no real time; synctest.Wait() after each sleep made sure the AfterFunc goroutine had run before
the assertion; and time.Since(start) reports exactly 48h0m0s, which is why this test is not flaky. The real-time
equivalent, with a 50 ms hold, still needs a real sleep and takes measurable time (0.06s against 0.00s):
package catalog
import (
"testing"
"time"
)
func TestHoldRealTime(t *testing.T) {
h := NewHolds()
h.Place("9780134685991", 50*time.Millisecond)
if !h.Held("9780134685991") {
t.Fatal("not held")
}
time.Sleep(60 * time.Millisecond)
if h.Held("9780134685991") {
t.Fatal("still held")
}
}
=== RUN TestHoldRealTime
--- PASS: TestHoldRealTime (0.06s)
PASS
ok example.com/shelf/catalog 0.241s
The test pattern for time is always: advance the clock with time.Sleep, then synctest.Wait(), then assert. The
Wait matters because sleeping only advances the clock; goroutines woken by it still need to run.
Testing tickers and contexts: Poll
TestPoll shows the second common use: a goroutine with a time.Ticker and a context. The test receives three values
and the fake clock reads exactly one, two and three minutes at the three ticks, without waiting. t.Context() inside a
bubble is already tied to the bubble, and cancelling a derived context lets the goroutine exit so synctest.Test can
return. Code that creates a context with context.WithTimeout, AfterFunc or WithDeadline can be tested the same way
(see Context and
Time and Timers for the time and context APIs themselves).
Wait versus Sleep (Go 1.27)
synctest.Sleep(d) is documented as equivalent to time.Sleep(d) followed by synctest.Wait(): it blocks until the
bubble’s clock has advanced by d and every other goroutine in the bubble is durably blocked. It shortens the most
common test step, "advance the clock and let the consequences settle". It exists in the Go 1.27.2 used here (verified with
go doc testing/synctest.Sleep, and listed in the Go 1.27 API file as pkg testing/synctest, func Sleep). On a toolchain
older than Go 1.27 write the two calls by hand.
TestWaitOrdering above shows the difference between the two operations. Wait alone does not advance time: the goroutine
sleeping for a second had not run its assignment (done=false, clock advanced 0s). synctest.Sleep(time.Second) moved
the clock by one second and returned after the goroutine had finished (done=true, 1s). The flag is a
sync/atomic.Bool because the test goroutine and the sleeping goroutine access it concurrently, and the race detector
(-race above) reports a plain bool there; a bubble provides a clock, not freedom from data races (see
Memory Model and Data Races).
Source: testing/synctest: Sleep
Failure modes
A bubble whose goroutines can never proceed is reported rather than hanging:
package catalog
import (
"testing"
"testing/synctest"
)
func TestDeadlock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan int)
<-ch
})
}
--- FAIL: TestDeadlock (0.00s)
panic: deadlock: all goroutines in bubble are blocked [recovered, repanicked]
...
goroutine 7 [chan receive (durable), synctest bubble 1]:
example.com/shelf/catalog.TestDeadlock.func1(0x65b0675bc488?)
.../catalog/deadlock_test.go:11 +0x30
...
FAIL example.com/shelf/catalog 0.190s
Source: testing/synctest: Test
The goroutine dump marks blocked goroutines as (durable) and names their bubble, so the stuck receive is easy to
find. (The stack frames in the middle are elided.) Leaking a goroutine that blocks forever, for example a worker that
nobody tells to stop, has the same result, so tests must shut their background goroutines down before f returns: the
bubble waits for them.
Using a bubbled channel from outside the bubble is a fatal error, not a panic that a test can recover from:
package catalog
import (
"testing"
"testing/synctest"
)
func TestOutside(t *testing.T) {
var ch chan int
synctest.Test(t, func(t *testing.T) {
ch = make(chan int, 1)
})
ch <- 1
}
fatal error: send on synctest channel from outside bubble
goroutine 19 [running]:
example.com/shelf/catalog.TestOutside(0x65eab307c248)
.../catalog/outside_test.go:13 +0x98
...
FAIL example.com/shelf/catalog 0.091s
Source: testing/synctest: Isolation
What synctest cannot do
-
Real network and file I/O block non-durably, so a bubble containing a goroutine reading a real socket never sees the clock advance. Use
net.Pipe, an in-memory fake, or (Go 1.27)httptest.NewTestServer, which uses an in-memory network meant for bubbles. -
Mutex waits are not durable blocks; code that holds a lock while another goroutine waits for the clock should be restructured or the wait made through a channel.
-
It does not make unsynchronised code race-free, and it does not explore interleavings; run bubble tests with
-race. -
It fakes the
timepackage’s clock only. Code that reads time through the operating system, such assyscallor a cgo library, sees real time.
Version notes
| Release | Change |
|---|---|
Go 1.18 |
Native fuzzing: |
Go 1.24 |
|
Go 1.25 |
|
Go 1.27 |
|
Where the books are outdated
Both books predate everything on this page except the plain benchmark mechanics:
-
Bodner (Learning Go, ch. 13) covers
go testand table-driven tests and touches on benchmarks, which at the time were written with the classicb.Nloop. The book predatesb.Loop(Go 1.24), native fuzzing (Go 1.18) andtesting/synctest(generally available in Go 1.25), so none of them is covered; new code should useb.Loopand, for time-dependent tests, a synctest bubble in place of sleeps and polling. -
Vivien (Learning Go Programming, ch. 12) covers
-bench,-benchmemand-benchtimewith theb.Nloop. The flags are all still valid; but-benchtimenow also acceptsNx, benchmarks useb.Loop, and there is no fuzzing or synctest.
On any difference, the pages cited below are authoritative.