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.Fatalfstops the current test goroutine;t.Errorfrecords the failure and continues. UseErrorfwhen later checks still tell you something.- Use
t.Cleanupinstead ofdeferin 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 xto reach unexported identifiers, orpackage x_testto 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| Signal | What it tells you |
|---|---|
ns/op | Wall time per operation — compare only between runs of the same benchmark |
B/op, allocs/op | Allocation pressure, which is usually the real cost in Go |
| Coverage percentage | Which lines executed at least once; not a statement about correctness |
| Fuzz corpus | Inputs 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.NewRecorderplushttptest.NewRequestexercises a handler with no sockets and no ports, so the tests stay fast and parallel-safe.httptest.NewServeris 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.Related
Building HTTP services The standard library toolkit
Last refreshed 2026-09-18.