Testing, benchmarking and fuzzing

Table-driven tests, subtests and helpers, benchmarks with allocation counts, fuzzing, httptest, and coverage that means something.

Table-driven tests

package pricing

import "testing"

func TestDiscount(t *testing.T) {
    t.Parallel()

    cases := []struct {
        name   string
        total  int
        member bool
        want   int
    }{
        {"no discount", 100, false, 100},
        {"member", 100, true, 90},
        {"free", 0, true, 0},
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {       // a subtest per row
            t.Parallel()
            if got := Discount(tc.total, tc.member); got != tc.want {
                t.Fatalf("Discount(%d, %v) = %d, want %d", tc.total, tc.member, got, tc.want)
            }
        })
    }
}

// t.Helper makes failure line numbers point at the caller
func mustParse(t *testing.T, s string) Config {
    t.Helper()
    c, err := Parse(s)
    if err != nil {
        t.Fatalf("Parse(%q) failed: %v", s, err)
    }
    return c
}
  • One table per behaviour, one subtest per case. Adding a case should mean adding a line, not copying a function.
  • t.Fatalf stops the current test goroutine; t.Errorf records the failure and continues. Use Errorf when later checks still tell you something.
  • Use t.Cleanup instead of defer in helpers, so cleanup runs after the test that called the helper, not when the helper returns.
  • t.Parallel() is cheap to add but must not be combined with shared mutable package state.
  • Tests live in package x to reach unexported identifiers, or package x_test to test only the public API. Keep one convention per directory.

Benchmarks, fuzzing and coverage

func BenchmarkEncode(b *testing.B) {
    data := makePayload()
    b.ReportAllocs()
    b.ResetTimer()                 // exclude setup from the measurement
    for i := 0; i < b.N; i++ {
        if _, err := encode(data); err != nil {
            b.Fatal(err)
        }
    }
}

func FuzzParsePort(f *testing.F) {
    f.Add("8080")
    f.Add("")
    f.Fuzz(func(t *testing.T, s string) {
        p, err := ParsePort(s)
        if err == nil && (p < 0 || p > 65535) {
            t.Fatalf("accepted out-of-range port %d from %q", p, s)
        }
    })
}
go test ./...
go test -run TestDiscount -v ./pricing
go test -race -count=1 ./...
go test -bench=. -benchmem ./pricing     # ns/op and B/op, allocs/op
go test -fuzz=FuzzParsePort -fuzztime=30s ./pricing
go test -coverprofile=cover.out ./... && go tool cover -html=cover.out
SignalWhat it tells you
ns/opWall time per operation — compare only between runs of the same benchmark
B/op, allocs/opAllocation pressure, which is usually the real cost in Go
Coverage percentageWhich lines executed at least once; not a statement about correctness
Fuzz corpusInputs that failed, saved under testdata/fuzz and replayed as ordinary tests
💡
Coverage measures execution, not verification. A hundred percent coverage with no assertions is a hundred percent of nothing. Chasing the last few percent usually produces tests that mirror the implementation and break on every refactor.

Testing HTTP handlers

func TestGetItem(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"id": "7"})
    }))
    defer srv.Close()          // also closes idle connections

    resp, err := srv.Client().Get(srv.URL)
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Fatalf("status = %d, want 200", resp.StatusCode)
    }
}

// the cheaper variant: no network at all
func TestHandlerDirect(t *testing.T) {
    req := httptest.NewRequest(http.MethodPost, "/v1/items", strings.NewReader("{}"))
    rec := httptest.NewRecorder()

    handler(rec, req)

    if rec.Code != http.StatusCreated {
        t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
    }
}
  • httptest.NewRecorder plus httptest.NewRequest exercises a handler with no sockets and no ports, so the tests stay fast and parallel-safe.
  • httptest.NewServer is for the client side of the story, or when the client needs a real URL — a redirect, a streaming body, a TLS variant.
  • Time-dependent code should take a clock or a timeout as a parameter. A test that sleeps for two seconds to prove a timeout works is a test that fails on a busy CI machine.
  • Outbound calls in tests should go to a fake server or an interface you control, never to the real third party.

FAQ

Where do integration tests go?
In a tests/ directory or a _test.go file in an external x_test package, often guarded by testing.Short() or a build tag so go test -short skips them locally.
How do I test code that calls time.Now?
Inject it. Accept a func() time.Time or a small clock interface, default it to time.Now, and pass a fixed function in tests. That keeps the test deterministic and instant.

Building HTTP services The standard library toolkit

Last refreshed 2026-09-18.