Advanced Testing

Updated

September 13, 2026

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 with f.Add, then f.Fuzz. go test already runs the seeds as ordinary tests. go test -fuzz=FuzzXxx keeps generating values until you stop it (or -fuzztime elapses).
  • func BenchmarkXxx(b *testing.B) — loop with for b.Loop() (Go 1.24+; this book is on 1.27). Do not use b.N in 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 desk

Save 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 test

Output (the duration varies):

PASS
ok      desk    0.003s

Optional, bounded fuzz:

go test -fuzz=FuzzParseTable -fuzztime=2s

Output 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 -benchmem

Output (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 test

Output (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 == niln > 0), not “every string parses.”
  • go test runs seeds. Use -fuzz when you want generation. Cap it with -fuzztime.
  • Benchmarks: for b.Loop() { ... }. Setup outside. go test -bench=. -benchmem.
  • Handlers: NewRequest + NewRecorder before you spin a server.
  • Check errors in tests. b.Fatal / t.Fatal exist for a reason.

Try this

  1. Add a seed f.Add("-3") to FuzzParseTable. go test should still pass (-3 is an error path).
  2. Run go test -bench=BenchmarkTotal -count=5 and look at the spread in ns/op. Do not treat one run as truth.
  3. In TestMenu, add a subtest POST /menu and decide whether your handler should 405. Implement that decision.