Advanced Testing
Advanced Testing
Tables cover the cases you thought of. Fuzzing throws bytes you did not. Benchmarks time a function. httptest gives a ResponseRecorder so a handler can be tested without a TCP port. The boring default is still a table. Add these when the function parses input, sits on a hot path, or is an HTTP handler.
Mental model
func FuzzXxx(f *testing.F)— seed withf.Add, thenf.Fuzz.go testalready runs the seeds as ordinary tests.go test -fuzz=FuzzXxxkeeps generating values until you stop it (or-fuzztimeelapses).func BenchmarkXxx(b *testing.B)— loop withfor b.Loop()(Go 1.24+; this book is on 1.27). Do not useb.Nin new code.httptest.NewRequest+httptest.NewRecorder— in-memory handler test. A full server is the next chapter.
Fuzz targets must be deterministic and must not fail on “bad” input if the function is supposed to return an error for junk. Assert invariants: if err == nil, the value is in range.
Worked examples
Case 1: A parser, a table test, and a fuzz target
ParseTable accepts "12" and rejects junk. Seeds run under go test. Fuzzing is optional extra.
Empty directory:
go mod init deskSave as table.go:
// table.go
package desk
import (
"fmt"
"strconv"
)
func ParseTable(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if n <= 0 {
return 0, fmt.Errorf("table %d: number must be positive", n)
}
return n, nil
}Save as table_test.go:
// table_test.go
package desk
import "testing"
func TestParseTable(t *testing.T) {
tests := []struct {
in string
want int
wantErr bool
}{
{in: "12", want: 12},
{in: "0", wantErr: true},
{in: "soup", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
got, err := ParseTable(tt.in)
if tt.wantErr {
if err == nil {
t.Fatalf("ParseTable(%q) = %d, nil; want error", tt.in, got)
}
return
}
if err != nil || got != tt.want {
t.Fatalf("ParseTable(%q) = %d, %v; want %d, nil", tt.in, got, err, tt.want)
}
})
}
}
func FuzzParseTable(f *testing.F) {
f.Add("12")
f.Add("1")
f.Add("0")
f.Fuzz(func(t *testing.T, s string) {
n, err := ParseTable(s)
if err != nil {
return
}
if n <= 0 {
t.Fatalf("ParseTable(%q) = %d, nil; invariant n > 0", s, n)
}
})
}Run (seeds only — no long fuzz):
go testOutput (the duration varies):
PASS
ok desk 0.003s
Optional, bounded fuzz:
go test -fuzz=FuzzParseTable -fuzztime=2sOutput looks like this (counts vary):
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 2s, execs: 20000 (10000/sec), new interesting: 4 (total: 7)
PASS
ok desk 2.050s
If fuzzing finds a crash, it writes a file under testdata/fuzz/. Keep that file; go test will replay it. Do not assert err != nil for every random string — ParseTable("8") is valid and would fail the fuzz.
Case 2: A benchmark with b.Loop
Total from a slice of cents. Setup sits outside the loop so you do not measure slice allocation.
Save as total.go:
// total.go
package desk
func Total(prices []int) int {
sum := 0
for _, p := range prices {
sum += p
}
return sum
}Save as total_test.go:
// total_test.go
package desk
import "testing"
func BenchmarkTotal(b *testing.B) {
prices := []int{450, 800, 250, 1250, 300}
for b.Loop() {
if Total(prices) != 3050 {
b.Fatal("unexpected total")
}
}
}Run:
go test -bench=BenchmarkTotal -benchmemOutput (ns/op and bytes/op vary by machine):
goos: linux
goarch: amd64
pkg: desk
BenchmarkTotal-16 80000000 14.20 ns/op 0 B/op 0 allocs/op
PASS
ok desk 1.150s
b.Loop excludes setup, keeps the compiler from deleting the call, and is the 1.27 default. Checking the result inside the loop is allowed and prevents the compiler from treating Total as dead. For a tighter bench, assign to a package-level var sink int instead of b.Fatal.
Case 3: httptest recorder for a handler
No listen socket. A fake request in, a recorded response out.
Save as menu_http.go:
// menu_http.go
package desk
import (
"net/http"
)
func Menu(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/menu" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("soup 400\n"))
}Save as menu_http_test.go:
// menu_http_test.go
package desk
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestMenu(t *testing.T) {
t.Run("ok", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/menu", nil)
Menu(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status %d, want %d", res.StatusCode, http.StatusOK)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(body) != "soup 400\n" {
t.Fatalf("body %q", body)
}
})
t.Run("missing", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
Menu(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status %d, want %d", rec.Code, http.StatusNotFound)
}
})
}Run:
go testOutput (the duration varies):
PASS
ok desk 0.003s
The _ = w.Write in the handler is the one place this book still discards an error: ResponseWriter.Write on a recorder does not fail. On a real ResponseWriter, check it or wrap the handler. The next chapter uses a real test server.
The trap
A fuzz function that fatals on every error. Random strings are supposed to fail Atoi. The fuzz then “finds” thousands of bugs that are the happy error path.
Save as badfuzz_test.go only if you want to see it go red immediately — do not keep it:
// badfuzz_test.go
package desk
import "testing"
func FuzzParseTableWrong(f *testing.F) {
f.Add("nope")
f.Fuzz(func(t *testing.T, s string) {
if _, err := ParseTable(s); err != nil {
t.Fatalf("unexpected error for %q: %v", s, err)
}
})
}go test runs the seed "nope", ParseTable returns an error, the seed fails. That is not a parser bug. Assert invariants on the success path.
The boring rule
- Write a table first. Fuzz the parser after the table is green.
- Fuzz invariants (
err == nil⇒n > 0), not “every string parses.” go testruns seeds. Use-fuzzwhen you want generation. Cap it with-fuzztime.- Benchmarks:
for b.Loop() { ... }. Setup outside.go test -bench=. -benchmem. - Handlers:
NewRequest+NewRecorderbefore you spin a server. - Check errors in tests.
b.Fatal/t.Fatalexist for a reason.
Try this
- Add a seed
f.Add("-3")toFuzzParseTable.go testshould still pass (-3is an error path). - Run
go test -bench=BenchmarkTotal -count=5and look at the spread in ns/op. Do not treat one run as truth. - In
TestMenu, add a subtestPOST /menuand decide whether your handler should 405. Implement that decision.