Go unit testing with the stdlib `testing` package - `func TestXxx(t *testing.T)` convention, the table-driven idiom with `t.Run` subtests, `t.Parallel()`, benchmarks (`BenchmarkXxx` + benchstat), examples (`ExampleXxx`), native fuzzing (`FuzzXxx`, Go 1.18+), coverage (`-cover` / `-coverprofile` + threshold gating), build tags, `t.Helper()` / `t.Cleanup`, and `-race` CI. Includes framework choice (stdlib `testing` is the idiomatic default; Ginkgo BDD for Kubernetes-ecosystem projects via references) and test-authoring conventions (framework detection from go.sum + existing suite files, `_test.go` placement, `t.Errorf` vs `t.Fatalf`). References cover Ginkgo + Gomega and Go mocking (gomock, testify/mock). Use for any Go unit-test task: writing table-driven tests, benchmarks, fuzz targets, coverage gates, or CI wiring.
72
91%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Per pkg.go.dev/testing:
Go's testing package is stdlib - no separate install, no configuration
file. The single binary go test discovers, builds, and runs tests via
convention: _test.go suffix; TestXxx / BenchmarkXxx / FuzzXxx /
ExampleXxx function-name prefixes. The table-driven idiom is built into
the language style, and benchmarks + fuzzing (Go 1.18+) are native.
testing is the idiomatic default - zero install, zero
config, works wherever Go works.k8s.io/*, sigs.k8s.io/*, knative.dev/* in go.mod) or the team
has an explicit BDD culture → references/ginkgo.md.go.sum plus a
*_suite_test.go bootstrap (or Describe/Context/It blocks in
existing tests) → stay on Ginkgo; otherwise stdlib. Never switch
frameworks mid-project.// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
if got := Add(1, 2); got != 3 {
t.Errorf("Add(1, 2) = %d; want 3", got)
}
}go test ./... # all packages recursively
go test -v # verbose
go test -run TestAdd # specific test by name patternPer pkg.go.dev/testing#hdr-Subtests_and_Sub_benchmarks:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b, expected int
}{
{"positive", 1, 2, 3},
{"zero", 0, 0, 0},
{"negative", -1, 1, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.a, tt.b); got != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.expected)
}
})
}
}t.Run creates subtests with hierarchical names (TestAdd/positive),
individually filterable via go test -run TestAdd/positive. A bare loop
without t.Run reports every failure under the parent name only.
func TestSomethingSlow(t *testing.T) {
t.Parallel() // marks this test as parallel-safe
}Parallel tests run concurrently with other parallel tests in the same
package. In subtest loops pre-Go 1.22, capture the loop variable
(tt := tt) or all subtests share the last iteration's value.
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}go test -bench=. -benchmem # with allocation tracking
go test -bench=. -count=10 > old.txt # statistical comparison:
benchstat old.txt new.txtfunc ExampleAdd() {
fmt.Println(Add(1, 2))
// Output: 3
}The // Output: comment is the assertion; examples appear in go doc.
Per pkg.go.dev/testing#hdr-Fuzzing:
func FuzzAdd(f *testing.F) {
f.Add(1, 2) // seed corpus
f.Add(-1, 1)
f.Fuzz(func(t *testing.T, a, b int) {
c := Add(a, b)
if c-a != b {
t.Errorf("Add(%d, %d) = %d; expected invariant", a, b, c)
}
})
}go test -fuzz=FuzzAdd -fuzztime=30sFailures are cached at testdata/fuzz/FuzzAdd/; subsequent go test
runs replay those cases as regression tests.
go test -cover # summary
go test -coverprofile=coverage.out -coverpkg=./... ./...
go tool cover -html=coverage.out # browser view
go tool cover -func=coverage.out # per-functionNo built-in threshold flag - gate via shell:
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print substr($3, 1, length($3)-1)}')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage $COVERAGE% below 80% threshold"; exit 1
fi//go:build integrationgo test -tags=integration ./... runs per-environment suites without a
separate folder structure.
func setupTest(t *testing.T) *Database {
t.Helper() // failure messages point to the caller
db := openTestDB()
t.Cleanup(func() { db.Close() })
return db
}- run: go test -race -coverprofile=coverage.out -v ./...
- uses: codecov/codecov-action@v4
with: { files: coverage.out }-race enables the race detector - standard practice for any Go project
with concurrency. JUnit XML for junit-xml-analysis (qa-test-reporting):
go install github.com/jstemmer/go-junit-report/v2@latest
go test -v ./... | go-junit-report > junit.xmlWhen authoring a new unit test in an existing project:
testing unless Ginkgo is
in go.sum AND an existing *_suite_test.go (or Describe blocks)
is present; when signals differ per sub-package, follow the target's
sub-package. Conflicting signals → stop and ask._test.go and live in the same
directory as the source (go-test-pkg). Same-package =
white-box (unexported access); package <name>_test = black-box.t.Errorf vs t.Fatalf: t.Errorf marks failed but continues;
t.Fatalf stops the test. Use t.Fatalf only when a broken
precondition would make later assertions panic or produce noise.TestXxx function; never modify existing tests,
never fabricate exported symbols the package does not declare, no smoke
asserts when the spec names a concrete value.| Anti-pattern | Why it fails | Fix |
|---|---|---|
Table loop without t.Run | Failures not individually named or filterable | Subtests (Step 2) |
| Forget loop-variable capture pre-Go 1.22 | All subtests see the last iteration | tt := tt (Step 3) |
Skip -race in CI | Data races ship to prod | Always -race (Step 9) |
t.Parallel() nowhere | Slow suite at scale | Mark parallel-safe tests (Step 3) |
Multiple checks in one t.Errorf | Fail-fast loses context | One assertion per logical thing |
testify is the common ecosystem
addition; plain if got != want is idiomatic.t.Cleanup + helper functions.testing package documentationgo test flagsrust-unit-tests - sister umbrella for Rusttest-code-conventions (qa-test-review) - test code hygiene