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

func BenchmarkXxx(b *testing.B)

go test -bench regexp

How fast is it, and how much does it allocate?

Fuzz test

func FuzzXxx(f *testing.F)

go test (seed corpus only) or go test -fuzz regexp (generate inputs)

Does it hold for inputs I did not think of?

synctest bubble

synctest.Test(t, func(t *testing.T) { ... })

go test (an ordinary test)

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

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)

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

Reading a result line:

  • BenchmarkParseISBN-10 is the benchmark name; the -10 suffix is GOMAXPROCS for the run. With GOMAXPROCS equal to 1 the suffix is omitted.

  • the first number (22046788) is how many iterations the final run executed (the value of b.N after the loop);

  • 54.68 ns/op is nanoseconds per iteration, derived from total time divided by iterations;

  • B/op and allocs/op (shown by -benchmem or b.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.

  1. 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 a b.N loop everything before the loop runs on every call; with b.Loop the 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 to b.Loop and stops it when it returns false.

  2. 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 exactly b.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

b.ResetTimer()

Zero the elapsed time, allocation counters and custom metrics; call after expensive setup in a b.N benchmark. b.Loop does this itself on its first call.

b.StopTimer(), b.StartTimer()

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.

b.ReportAllocs()

Report B/op and allocs/op for this benchmark even without -benchmem.

b.SetBytes(n)

Declare that one iteration processes n bytes; the output gains an MB/s column.

b.ReportMetric(n, unit)

Add a custom metric, for example b.ReportMetric(float64(hits)/float64(b.N), "hits/op").

b.Elapsed()

Time measured so far, for computing custom metrics.

b.Run(name, f)

Sub-benchmark (below).

b.RunParallel(f)

Run the body in GOMAXPROCS goroutines (below).

b.Setenv, b.TempDir, b.Cleanup, b.Context, b.Chdir

Same helpers as on testing.T.

b.Skip, b.Fatal, b.Error, b.Log

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.

Flags for running benchmarks

Flag Meaning

-bench regexp

Run the benchmarks that match. -bench . runs all; / separates the levels of sub-benchmarks (-bench 'StoreAll/books=100$').

-benchmem

Print B/op and allocs/op for every benchmark (as b.ReportAllocs does for one).

-benchtime t

How long to run each benchmark; a duration (-benchtime=5s, default 1s) or an exact iteration count written with an x suffix (-benchtime=1000x).

-count n

Run each benchmark (and test) n times; the way to get samples for statistics.

-cpu list

Run with each GOMAXPROCS value in a comma-separated list (-cpu 1,2,4).

-run regexp

Which tests to run; -run '^$' matches none, so only benchmarks run.

-cpuprofile, -memprofile, -blockprofile, -mutexprofile, -trace

Write a profile or execution trace of the run.

-timeout d

Abort the whole test binary after d (default ten minutes); long -benchtime runs may need more.

-list regexp

List matching tests, benchmarks, fuzz tests and examples without running them.

-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

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

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

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

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.Loop handles dead-code elimination for calls inside its braces. In a b.N loop 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.Log and fmt.Println inside 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=10 is measuring the machine. Close other work, disable power saving, pin to a quiet host, and prefer -cpu 1 unless concurrency is the point.

  • Benchmark runs are never served from the test cache, because -bench is not one of the cacheable flags (-benchtime, -cpu, -run, -short, -timeout, -v and a few others are). A plain go test run of the same package can be cached (see Testing); -count=1 is 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.

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)
		}
	})
}

The rules:

  • the function is named FuzzXxx, takes only *testing.F, and lives in a _test.go file;

  • 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.T parameter;

  • f.Fuzz(func(t *testing.T, args...) { ... }) is called exactly once and registers the fuzz target; the arguments (after t) are the fuzzed inputs;

  • permitted argument types are string, []byte, bool, float32, float64, and all integer types (int, int8 to int64, uint, uint8 to uint64); rune and byte are aliases of permitted types. Nothing else compiles: a struct parameter is rejected by go vet and 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

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

Reading the output:

  • gathering baseline coverage runs every seed and corpus entry once to record which code they reach;

  • now fuzzing with 10 workers means ten worker processes (the -parallel default is GOMAXPROCS);

  • 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 (default 60s; 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 (\x91 above) for non-printable bytes. The lines can be copied into Go code, for instance as arguments to f.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

Where the files live:

Location Role

f.Add(...) calls

The seed corpus in code.

testdata/fuzz/FuzzXxx/* in the package

The checked-in corpus: failures written by the fuzzer plus any hand-added files. go test replays these in every run; commit them.

$GOCACHE/fuzz/<import path>/FuzzXxx/

The generated corpus: the "interesting" inputs the fuzzer has accumulated. It is reused by later -fuzz runs on the same machine, but never by plain go test, and it is not committed. go clean -fuzzcache deletes it.

The fuzzing loop

flowchart TD A["go test -fuzz FuzzXxx"] --> B["Run the seed corpus: f.Add and testdata/fuzz"] B -->|a seed fails| X["Report the failure and stop"] B --> C["Gather baseline coverage"] C --> D["Pick a corpus entry and mutate it"] D --> E["Run the fuzz target in a worker process"] E -->|target fails or panics| F["Minimize the input"] F --> G["Write testdata/fuzz/FuzzXxx/hash"] G --> X E -->|reaches new code| H["Add it to the generated corpus in GOCACHE"] H --> D E -->|nothing new| D D -->|fuzztime elapsed| Z["PASS"]

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 regexp

Fuzz the single fuzz test that matches. It must match exactly one fuzz test in exactly one package.

-fuzztime t

Total time (-fuzztime=30s) or number of target executions (-fuzztime=2000x); default: run until stopped or a failure.

-fuzzminimizetime t

Budget for each minimization attempt; default 60s; also accepts Nx.

-parallel n

Maximum concurrent worker processes while fuzzing (default GOMAXPROCS).

-run regexp

Which tests run first; -run '^$' skips them. Without it the matching unit tests, benchmarks and corpora run before fuzzing starts.

-count, -cpu

Do not apply to the fuzz test selected by -fuzz.

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

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.Skip as 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

The rules of a bubble

  • synctest.Test(t, f) runs f in a new bubble and returns when every goroutine started in it has exited. It must not be called from inside a bubble. The *testing.T passed to f is the bubble’s: Cleanup functions run inside the bubble just before Test returns, t.Context() has a Done channel associated with the bubble, and t.Run, t.Parallel and t.Deadline must not be called.

  • Goroutines started with go inside the bubble belong to it.

  • Inside the bubble the time package 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 select where every case is a channel created in the bubble; sync.Cond.Wait; sync.WaitGroup.Wait when Add was called within the bubble; time.Sleep.

  • These do not block durably, because something outside the bubble could wake them: locking a sync.Mutex or sync.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 Wait has not been called, the bubble is deadlocked and Test panics.

  • A channel, timer or ticker created in a bubble is associated with it; operating on it from outside the bubble panics. A sync.WaitGroup becomes associated with the bubble on its first Add or Go; Cond.Wait is durably blocking.

  • Cleanup functions and finalizers registered with runtime.AddCleanup and runtime.SetFinalizer run outside any bubble.

flowchart TD A["Bubble running: some goroutine can make progress"] --> B{"Every goroutine durably blocked?"} B -->|no| A B -->|yes| C{"Is synctest.Wait waiting?"} C -->|yes| D["Wait returns, clock unchanged"] C -->|no| E{"Pending timer or sleep, and root goroutine alive?"} E -->|yes| F["Clock jumps to the next wake-up time"] F --> A E -->|no| G["Deadlock: Test panics"] D --> A

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).

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

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

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 time package’s clock only. Code that reads time through the operating system, such as syscall or a cgo library, sees real time.

Version notes

Release Change

Go 1.18

Native fuzzing: testing.F, go test -fuzz, testdata/fuzz, corpus file format.

Go 1.24

testing.B.Loop; testing/synctest as an experiment (GOEXPERIMENT=synctest).

Go 1.25

testing/synctest generally available with synctest.Test and synctest.Wait; the old synctest.Run stays only behind GOEXPERIMENT=synctest, announced for removal in Go 1.26.

Go 1.27

synctest.Sleep; httptest.NewTestServer for use with synctest.

Where the books are outdated

Both books predate everything on this page except the plain benchmark mechanics:

  • Bodner (Learning Go, ch. 13) covers go test and table-driven tests and touches on benchmarks, which at the time were written with the classic b.N loop. The book predates b.Loop (Go 1.24), native fuzzing (Go 1.18) and testing/synctest (generally available in Go 1.25), so none of them is covered; new code should use b.Loop and, for time-dependent tests, a synctest bubble in place of sleeps and polling.

  • Vivien (Learning Go Programming, ch. 12) covers -bench, -benchmem and -benchtime with the b.N loop. The flags are all still valid; but -benchtime now also accepts Nx, benchmarks use b.Loop, and there is no fuzzing or synctest.

On any difference, the pages cited below are authoritative.