Testing & Benchmarks
Table tests, httptest, benchmarks, fuzzing, -race in CI
- 01
A colleague's table test loops over a slice of cases and fails with
got 4m0s, want 5m0s— nothing says which case. Beyond a nicer name, what does wrapping the body int.Runactually buy you?Easyt.Runmakes each case a real test of its own — its own name, its own*testing.T, its own failure boundary — so a broken case names itself, fails alone, and can be re-run without touching the table.func TestParseDuration(t *testing.T) { cases := []struct { name string in string want time.Duration wantErr bool … - 02
Every failure in the suite points at line 61 of
helpers_test.goinstead of the test that broke, and the same fixture helper cannot be reused from a benchmark. Two one-line fixes — which?Easytb.Helper()as the first statement andtesting.TBas the parameter type: the first makes the failure report the caller's file and line, the second makes one helper serve*testing.T,*testing.Band*testing.Falike.// testing.TB, not *testing.T: the same fixture works from a benchmark. func mustStore(tb testing.TB, seed ...Row) *Store { tb.Helper() // failures below are reported at the CALLER's line s, err := Open(tb.TempDir()) if err != nil { … - 03
A fixture helper opens a temp database and returns it plus a
func()the caller is supposed todefer— and half the tests forget. How doest.Cleanupfix the helper, and when isdeferstill the right tool?Easyt.Cleanuplets the helper register its own teardown, so the resource is released by whoever created it rather than by adeferat every call site — and unlikedeferit fires when the test and all its subtests are done.// ❌ the caller has to remember the teardown, and half of them will not func openDBWithCleanup(t *testing.T) (*sql.DB, func()) { db := dial(t) return db, func() { db.Close() } } … - 04
CI goes green in four seconds and prints
ok example.com/store (cached)for a package whose fixture file you just rewrote. What exactly isgo testcaching, and what defeats it?Easygo testcaches successful package results, keyed on the test binary, the command line and the files and environment variables the run actually touched — and-count=1is the idiomatic way to force a real run.// The cache records the files a test opened INSIDE the module and the // environment variables it read. These two read the same bytes and cache // completely differently. func TestGoldenInsideModule(t *testing.T) { // testdata/ is inside the module: edit it and the entry is invalidated. … - 05
for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel(); check(t, tc) }) }checks every subtest against the last case on Go 1.21 and is correct on 1.22. What changed — and what else doest.Parallel()quietly reorder?MediumGo 1.22 made
forloop variables per-iteration, sotcis a fresh variable each turn and the paused parallel subtests no longer all close over the one variable the finished loop left pointing at the last case.func TestValidate(t *testing.T) { cases := []struct { name string in Payload }{{"empty", Payload{}}, {"huge", Payload{Size: 1 << 30}}} … - 06
A test spawns a worker goroutine that calls
t.Fatalfwhen the response is wrong. Sometimes the suite hangs until the 10-minute timeout; sometimes the failure is reported against a completely different test. What is wrong with that call?Mediumt.Fatalmay only be called from the goroutine running the test: it marks the failure and then callsruntime.Goexit, which terminates only the goroutine that called it — the test function itself keeps going.// ❌ Fatalf on a worker goroutine: Goexit kills the worker, not the test. func TestFetchBad(t *testing.T) { var wg sync.WaitGroup wg.Add(1) go func() { resp, err := fetch(context.Background(), "/health") … - 07
You add a test for
storethat imports astoretesthelper package, which importsstore— and the build dies with an import cycle. Renaming the test file's package tostore_testfixes it. What is that package, and what do you give up?Mediumpackage foo_testis an external test package: the go tool compiles it separately and links it into the same test binary, so it may importfooand anything that importsfoowithout creating a cycle.// store/export_test.go — package store, compiled only under `go test` package store // Hand the external test package a controlled view of the internals. var ( ParseKey = parseKey … - 08
The renderer's test embeds a 200-line expected string; every template tweak means retyping it and the review diff is unreadable. How do you move to golden files without building a test that can never fail?
MediumKeep the expected output in
testdata/and regenerate it only behind an explicit-updateflag, so the ordinary run compares against a committed file and rewriting it is a deliberate act that shows up in review.var update = flag.Bool("update", false, "rewrite testdata golden files") func TestRenderInvoice(t *testing.T) { for _, name := range []string{"single_line", "multi_currency", "zero_total"} { t.Run(name, func(t *testing.T) { got := normalise(Render(loadCase(t, name))) … - 09
You need tests for a handler and for the client that calls it. One wants
httptest.NewRecorder, the otherhttptest.NewServer— which goes where, and what does the recorder not reproduce?MediumThe recorder tests a handler in-process, the server tests everything below it:
httptest.NewRecorderis a fakehttp.ResponseWriteryou pass straight toServeHTTP, whilehttptest.NewServerstarts a realhttp.Serveron a loopback port so a real client, transport and wire encoding are involved.// Handler under test: no socket, no client, no transport. func TestCreateOrder(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/orders", strings.NewReader(`{"sku":"A1","qty":2}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() … - 10
A token-expiry test sleeps two seconds and still fails about once a week on CI. How do you test the expiry without a clock you can't control, and why not reach for a mocking framework while you're in there?
MediumTake the non-deterministic dependency as a small interface the caller supplies — one method,
Now() time.Time— so the test hands it a fixed instant and the sleep, along with the flake, disappears.// The seam is declared where it is USED and has exactly one method. type Clock interface{ Now() time.Time } type systemClock struct{} func (systemClock) Now() time.Time { return time.Now() } … - 11
The suite takes nine minutes because a third of it talks to a real Postgres, and contributors without Docker can't run anything at all. How do you split the two lanes without maintaining two commands per package?
MediumPut the tests that need a server behind a build tag —
//go:build integration— sogo test ./...never even compiles them and CI adds-tags=integrationfor the slow lane.//go:build integration // The blank line above `package` is required. Without it the directive is // just a comment and this file compiles in every build. package store_test … - 12
A manager sets a 100% line-coverage gate. Six weeks later the number is 100% and an outage ships anyway. What does
go test -coveractually measure, and where does the gate go wrong?MediumCoverage measures which statements the test binary executed, not whether anything was asserted — it can prove code is untested, never that the code that ran is correct.
func Withdraw(a *Account, cents int64) error { if cents <= 0 { return ErrAmount } if a.Balance < cents { return ErrInsufficient … - 13
BenchmarkScalewraps a two-line pure function and reports0.25 ns/opwithb.Npinned at 1000000000. Before you take that to the pull request — what did you actually measure?HardNothing: the compiler inlined the call, saw the result was unused and deleted the loop body, leaving you the cost of incrementing
i— about one cycle, which is exactly what 0.25 ns/op on a modern core means.func scale(v, f int) int { return v*f + 1 } var Sink int // exported: the compiler cannot prove stores to it are dead // ❌ 0.25 ns/op. scale is inlined, the result is unused, the body is gone. func BenchmarkScaleNaive(b *testing.B) { … - 14
You optimise a serializer, run
go test -bench=Encodeonce before and once after, and the second number is 9% lower. Your reviewer says that proves nothing. What does a defensible comparison look like?HardOne run per side is one sample: run each side with
-count=10into a file and letbenchstatsay whether the difference is bigger than the noise.// A serial benchmark of a lock measures the uncontended fast path. func BenchmarkCacheGet(b *testing.B) { c := New(1 << 16) warm(c) b.ReportAllocs() b.ResetTimer() … - 15
Your header parser survives 40 table cases and still panicked in production on a string sent by a real client. How does a Go fuzz target differ from those 40 cases, and what makes one worth running?
HardA fuzz target states a property, not an example:
f.Fuzzhands your function coverage-guided mutations of the seed corpus for as long as you let it run, so it reaches inputs nobody thought to write down.func FuzzParseHeader(f *testing.F) { // Seeds are the regressions you already know about. Without -fuzz they // run as a plain unit test, so CI keeps them forever for free. f.Add("Bearer abc123") f.Add("") f.Add("Bearer \x00\x00") … - 16
About one CI run in twenty fails — never the same test, always green on a re-run. Your team has started adding retries. How do you turn that into a bug someone can actually fix?
HardStop re-running it and make the suite hostile instead:
-race,-count=Nand-shuffle=onturn the three real causes — a data race, order dependence and shared state — into deterministic failures.// Package-level state makes test ORDER part of the contract. var registry = map[string]Handler{} // ❌ passes alone, passes in file order, fails under -shuffle=on func TestRegisterAndDispatch(t *testing.T) { registry["pay"] = payHandler{} …