Testing Best Practices
Test pyramid guidance for Go services and libraries.
These rules keep Go tests fast, readable, and trustworthy from local go test through CI merge gates.
How to Use This List
- Apply during design and review when tests are an afterthought or CI time explodes.
- Run
go test -race ./...and golangci-lint on the same packages you gate in CI. - Prefer adding a table row over a one-off test function when behavior is parametrized.
- Revisit fuzz corpora and golden files whenever output format intentionally changes.
A - Structure and readability
- Default to table-driven tests with named subtests. Reviewers see cases as data; failures name the row via
t.Run. - Keep tests beside production code in
*_test.go. Same module, same refactor, same compile errors when APIs move. - Use
t.Helper()on shared assertion helpers. Failures point at the test line, not utility internals. - Test exported behavior from
package foo_testwhen modeling consumers. Internal tests stay inpackage foofor unexported helpers. - One logical assertion path per subtest when possible. Mixed checks make failures harder to diagnose.
B - Speed and determinism
- Target
go test ./...in seconds to low minutes on laptops. Split integration suites with build tags or separate modules when needed. - Avoid
time.Sleepfor synchronization. Use channels,sync.WaitGroup, or controllablehttptesthandlers. - Mock IO boundaries, not every private function. Hand-written fakes behind small interfaces beat generated mocks for narrow APIs.
- Run
go test -count=1when debugging flakes. Cached passes hide ordering bugs until CI reruns. - Hoist benchmark setup outside
b.Loop(). Measure the hot path, not fixture construction.
C - HTTP, databases, and services
- Use
httptest.NewRecorderfor handler unit tests. ReserveNewServerwhen the client stack must run for real. - Close
resp.Bodyafter every client call in tests. Leaks fail under-count=100and load-like CI. - Limit real database tests to a thin smoke layer. Keep business logic tests on fakes; use containers for SQL integration sparingly.
- Thread
r.Context()through handlers in tests. Cancellation and deadline middleware only work when handlers respect context. - Document which tests require Docker or network. Mark with build tags so default
go teststays offline.
D - CI quality signals
- Run
-raceon packages that spawn goroutines or share caches. Accept the CPU cost on Linux CI agents. - Track coverage on critical packages, not vanity global percent. Pair coverage with assertions on error branches.
- Commit fuzz crashers and golden files under
testdata/. Treat diffs as regression fixtures requiring human review. - Schedule bounded fuzz jobs (
-fuzztime) nightly or weekly. Replay corpora on every PR without open-ended fuzz in merge pipelines. - Order CI: format/vet/lint, unit tests, race build, integration smoke. Align with linting-quality gate templates.
E - Documentation and evolution
- Add
Examplefunctions for copy-paste public API usage. Keep// Output:comments accurate so godoc stays verified. - Update examples when exported behavior changes.
go testfails when docs drift. - Prefer
errors.Is/errors.Asin error table rows. Wrapped errors break==comparisons. - Review testify
requirevsassertpolicy per team.requirefor preconditions;assertfor independent checks. - Profile before micro-optimizing from benchmarks alone. Use
pprofon realistic workloads afterbenchstatshows a win.
FAQs
How many integration tests should a Go service have?
Enough to prove wiring (DB, auth, one HTTP path).
Most cases stay in fast unit and handler tests.
Should CI fail on coverage drop?
Optional on core packages.
Never substitute percent for meaningful assertions.
When is testify worth the dependency?
When large struct comparisons and JSONEq improve failure readability.
Stdlib remains fine for small packages.
How do I test main() or CLI commands?
Extract logic into testable functions or use os/exec to run a built binary with testdata args.
Should benchmarks block merge?
Usually no - use nightly benchstat unless the change is explicitly performance-critical.
Where do build tags fit?
//go:build integration on slow tests keeps default go test fast.
Document the tag in README and CI.
How do I test log output?
Inject io.Writer or use log/slog with a bytes.Buffer handler.
Avoid golden logs with timestamps.
Can t.Parallel run everywhere?
Only when subtests do not share mutable package state.
Race detector catches mistakes.
How do generics change testing?
Tables and fakes work the same - instantiate types in rows or use typed helpers.
What about e2e tests outside go test?
Keep them in a separate pipeline or nightly job.
Unit and handler tests remain the merge gate.
Related
- Go Testing Culture: Simple, Fast, Table-Driven - section overview
- Go Testing Basics - runnable intro examples
- Coverage, Race Detector & Golden Files - CI signals in depth
- Fuzz Testing with testing.F - corpora and scheduled fuzz
- CI Quality Gates: Lint, Test, Race, Vuln - full pipeline ordering
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).