Go Testing
Tests are executable specifications. Their job is to fail usefully when behaviour regresses — and to keep failing in the same way until the bug is fixed. Tests that are passing-or-flaky teach the team to ignore them, which is worse than no test at all.
Core Rules
- Failures must be diagnosable from the log alone. Every
t.Errorfincludes the function under test, the inputs, what we got, and what we wanted, in that order. - No assertion libraries by default. Use the standard
t.Errorf/t.Fatalfplusgo-cmpfor structural comparison.testifyis acceptable when adopted consistently — pick one and stick with it. - Test observable behaviour, not implementation details. If a refactor that preserves behaviour breaks the test, the test was wrong.
- Each test runs independently. No execution-order dependencies, no shared global state without
t.Cleanup. t.Parallel()whenever the test is safe to run in parallel. Most are.t.Helper()is the first line of any helper function that callst.Errorf/t.Fatalf. Reservet.Fatalfor "next line is meaningless without this value"; everything else usest.Error. Never callt.Fatal/t.FailNowfrom a non-test goroutine — send the failure back via channel.
"Useful Failures" Format
The failure message is the test's user interface. The canonical shape:
FunctionUnderTest(input) = got, want want
// Good
t.Errorf("Add(2, 3) = %d, want %d", got, 5)
// Bad — no function, no inputs, reversed
t.Errorf("expected %d but got %d", 5, got)
Always print got before want. With cmp.Diff(want, got), the diff shows (-want +got) — echo that direction in your message.
"No Asserts" Philosophy
Standard-library testing with if + t.Errorf reads as plain Go and produces messages you control. Assertion libraries shorten call sites but trade away message quality and reorder the got/want convention.
- Default —
if got != want { t.Errorf("...") }pluscmp.Difffor structs, slices, maps, protos. - Permitted —
testifyif the team agrees andtestifylintis enabled.requireonly when continuing is meaningless. - Avoid — mixing styles within the same package.
For protocol buffers, add protocmp.Transform() as a cmp option. Don't diff serialised JSON strings — decode and cmp.Diff instead.
t.Error vs t.Fatal
Use t.Error by default; reserve t.Fatal for "the next line is meaningless without this value" (failed setup, failed decode before use). Never call t.Fatal/t.FailNow from a goroutine other than the test goroutine — it does not stop the test. Send the failure back via channel.
Read references/assertions-and-helpers.md when designing helpers, custom comparers, or migrating between stdlib testing and
testify.
Table-Driven Tests
func TestCalculatePrice(t *testing.T) {
tests := []struct {
name string
quantity int
unitPrice float64
want float64
}{
{"single item", 1, 10.0, 10.0},
{"bulk discount", 100, 10.0, 900.0},
{"zero quantity", 0, 10.0, 0.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := CalculatePrice(tt.quantity, tt.unitPrice)
if got != tt.want {
t.Errorf("CalculatePrice(%d, %.2f) = %.2f, want %.2f",
tt.quantity, tt.unitPrice, got, tt.want)
}
})
}
}
Every case has a name used in t.Run; failure messages include inputs, not the row index. When cases need different mocks or assertion shapes, stop using a table and write separate functions.
Helpers, Cleanup, Parallel
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil { t.Fatalf("open db: %v", err) }
t.Cleanup(func() { _ = db.Close() })
return db
}
t.Helper() is the first line of any helper that may fail; t.Cleanup runs after the test (and subtests) in LIFO order. Call t.Parallel() inside the subtest function. The paralleltest linter catches missing calls and the pre-1.22 loop-variable trap.
HTTP Handlers
Use httptest with table-driven cases. See references/http-and-fakes.md for request/response body, header, and status assertions.
Goroutine Leaks: goleak
Wire go.uber.org/goleak into every package that spawns goroutines:
import "go.uber.org/goleak"
func TestMain(m *testing.M) { goleak.VerifyTestMain(m) }
Per-test: defer goleak.VerifyNone(t). Exclusions go to goleak.IgnoreTopFunction(...) — avoid IgnoreAnyFunction.
Deterministic Time: testing/synctest
For timer/context/deadline tests, testing/synctest (Go 1.25+) gives reproducible ordering. Synthetic time advances only when every goroutine in the bubble is blocked:
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
time.Sleep(5 * time.Second)
synctest.Wait()
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatalf("got %v, want DeadlineExceeded", ctx.Err())
}
})
Use synctest.Test on Go 1.25+ and 1.26+. The Go 1.24 GOEXPERIMENT=synctest synctest.Run API is only for modules still on 1.24.
Fuzzing and Benchmarks
Native fuzzing finds inputs you would never write by hand. Seed with f.Add, then f.Fuzz; check fuzzer-discovered corpora (testdata/fuzz/...) into git as regression tests.
Benchmarks use for b.Loop() on Go 1.24+ (the legacy b.N loop only when targeting older versions). Sub-benchmarks across sizes use b.Run(fmt.Sprintf("n=%d", n), ...). For methodology, benchstat, and CI regression detection, see a dedicated go-benchmark skill — not this one.
Read references/fuzz-synctest-bench.md when wiring fuzz corpora into CI, designing
synctest-based timer tests, or writing comparable benchmark suites withb.Loop.
Integration Tests
Separate by build tag so go test ./... stays fast:
//go:build integration
package mypackage_test
Run with go test -tags=integration ./.... Integration tests own their fixtures (containers, schemas, fixtures) via t.Cleanup.
Read references/http-and-fakes.md when writing HTTP handler tests, mocking via consumer-owned interfaces, or stubbing time.
Anti-Patterns
| Anti-pattern | Do this instead |
|---|---|
t.Errorf("got %d", got) without the function or wanted value |
FunctionUnderTest(input) = got, want want |
Comparing error strings (err.Error() == "...") |
errors.Is / errors.As |
Calling t.Fatal from a spawned goroutine |
Send via channel; main goroutine calls t.Fatal |
| Tables of cases with conditional setup per row | Split into separate test functions |
Subtests without t.Run(tt.name, ...) |
Always name and t.Run |
| Mocking concrete types from another package | Define a small interface in the consumer; pass a fake |
time.Sleep to wait for "the goroutine to do its thing" |
synctest.Test or explicit synchronisation |
| Snapshot tests that compare serialised JSON | Decode then cmp.Diff |
Mutating os.Args/env/flag.CommandLine without t.Cleanup |
Save and restore in t.Cleanup |
Verification Checklist
- Every failure message includes function, inputs, got, and want, in that order
-
cmp.Diffcalls use(-want +got)order and echo it in the message - Table-driven cases have
namefields and uset.Run - Helpers call
t.Helper()and uset.Cleanupfor teardown - Parallel-safe tests call
t.Parallel();paralleltestis clean - Packages that spawn goroutines wire
goleak.VerifyTestMainor per-testVerifyNone - Timer/deadline tests use
testing/synctest, nottime.Sleep - Integration tests are gated by a build tag and own their fixtures
-
go test -race ./...is clean; fuzz corpora are checked intotestdata/fuzz/...
References
- references/assertions-and-helpers.md — useful failures,
cmp.Diff, helpers,testifyinterop - references/http-and-fakes.md —
httptest, consumer-owned fakes, time stubs - references/fuzz-synctest-bench.md — fuzzing,
testing/synctest,b.Loopbenchmarks - references/goleak-and-flakes.md — leak detection, flake diagnosis, race triage