go-unit-tests
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.
Install with skills.sh (any agent)
npx skills add testland/qa --skill go-unit-testsgo-unit-tests
Overview
Per pkg.go.dev/testing (opens in new window):
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.
Choosing a framework
Step 1 - First test
// 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 patternStep 2 - Table-driven tests (the Go idiom)
Per pkg.go.dev/testing#hdr-Subtests_and_Sub_benchmarks (opens in new window):
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.
Step 3 - t.Parallel()
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.
Step 4 - Benchmarks
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.txtStep 5 - Examples (executable docs)
func ExampleAdd() {
fmt.Println(Add(1, 2))
// Output: 3
}The // Output: comment is the assertion; examples appear in go doc.
Step 6 - Native fuzzing (Go 1.18+)
Per pkg.go.dev/testing#hdr-Fuzzing (opens in new window):
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.
Step 7 - Coverage
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
fiStep 8 - Build tags and helpers
//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
}Step 9 - CI integration
- 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.xmlAuthoring conventions
When authoring a new unit test in an existing project:
Anti-patterns
| 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 |
Limitations
References
Ginkgo + Gomega - Go BDD testing (reference)
View source (opens in new window)Ginkgo + Gomega - Go BDD testing (reference)
Companion reference for go-unit-tests. Consult for Kubernetes-ecosystem projects (the community convention) or teams with a BDD culture (rspec/mocha background). For non-BDD Go projects, stdlib testing (SKILL.md) is the idiomatic choice.
Per onsi.github.io/ginkgo (opens in new window):
Install and bootstrap
go install github.com/onsi/ginkgo/v2/ginkgo@latest
go get github.com/onsi/ginkgo/v2
go get github.com/onsi/gomega/...
ginkgo bootstrap # creates <package>_suite_test.go
ginkgo generate calc # creates calc_test.go templateThe bootstrap file registers the suite:
package calc_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestCalc(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Calc Suite")
}Spec structure
var _ = Describe("Calculator", func() {
var c *calc.Calculator
BeforeEach(func() {
c = calc.New()
})
Describe("Add", func() {
Context("with positive numbers", func() {
It("adds correctly", func() {
Expect(c.Add(1, 2)).To(Equal(3))
})
})
Context("with overflow", func() {
It("returns error", func() {
_, err := c.AddSafe(math.MaxInt, 1)
Expect(err).To(HaveOccurred())
})
})
})
})var _ = Describe(...) registers the spec at package init time. Hooks (BeforeSuite / AfterSuite, BeforeEach / AfterEach, JustBeforeEach / JustAfterEach) nest with Describe/Context - inner BeforeEach runs in addition to outer ones (gn-docs (opens in new window)).
An It block with no Gomega Expect passes silently - always assert.
Gomega matchers
Per onsi.github.io/gomega (opens in new window) - Expect(actual).To(matcher) and Expect(actual).NotTo(matcher):
Expect(value).To(Equal(expected)) // reflect.DeepEqual semantics
Expect(value).To(BeNil())
Expect(err).To(HaveOccurred())
Expect(err).To(MatchError("expected message"))
Expect(str).To(ContainSubstring("substring"))
Expect(str).To(MatchRegexp(`\d+`))
Expect(list).To(HaveLen(3))
Expect(list).To(ContainElement("alice"))
Expect(list).To(ConsistOf("alice", "bob")) // unordered
Expect(value).To(BeNumerically(">", 0))
Expect(value).To(BeNumerically("~", 3.14, 0.01)) // tolerance
Expect(action).To(Panic())
Expect(channel).To(Receive(&value))Async polling - Eventually polls until the condition holds; Consistently verifies it stays true (use instead of sleep-based polls):
Eventually(func() bool { return ready() }).Should(BeTrue())
Consistently(func() bool { return stable() }).Should(BeTrue())DescribeTable + Entry (parametrize)
DescribeTable("Add",
func(a, b, expected int) {
Expect(calc.Add(a, b)).To(Equal(expected))
},
Entry("positive", 1, 2, 3),
Entry("zero", 0, 0, 0),
Entry("negative", -1, 1, 0),
)Focus, skip, parallel
FDescribe / FIt focus (only those run); PDescribe / PIt skip. Parallel: ginkgo -p ./... (CPU count) or -procs=4 - per-process, so tests must be independent.
CI integration
- run: go install github.com/onsi/ginkgo/v2/ginkgo@latest
- run: ginkgo -p --cover --coverprofile=coverage.out --no-focus -r
- uses: codecov/codecov-action@v4
with: { files: coverage.out }--no-focus fails the build if any F-prefix specs exist (catches debug-leftover focus). JUnit XML: ginkgo --junit-report=junit.xml -r.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Ginkgo for a non-BDD codebase | Verbose vs stdlib testing | stdlib (SKILL.md) |
Committed FDescribe / FIt | Suite runs only focused specs | --no-focus in CI |
| Sleep-based async assertions | Flaky | Eventually / Consistently |
| Heavy nesting (5+ levels) | Setup hard to reason about | Flatten with Describe+It |
References
Go mocking - gomock and testify/mock (reference)
View source (opens in new window)Go mocking - gomock and testify/mock (reference)
Companion reference for go-unit-tests. A test double (per ISTQB Glossary (opens in new window)) replaces a real dependency so the subject under test runs in isolation. Use when a unit test reaches a database, HTTP client, file system, or any interface boundary; for tests that do not cross an interface boundary, prefer real objects or simple hand-written stubs without a mocking library.
| Tool | Approach |
|---|---|
go.uber.org/mock (gomock + mockgen) | Codegen from interface |
github.com/stretchr/testify/mock | Hand-written stub struct |
gomock (go.uber.org/mock)
Install and generate
Per github.com/uber-go/mock (opens in new window):
go get go.uber.org/mock/gomock
go install go.uber.org/mock/mockgen@latest
# Source mode: generates from a .go file
mockgen -source=internal/store/store.go \
-destination=internal/store/mock_store.go \
-package=store
# Package mode: package + interface names
mockgen github.com/myorg/myapp/internal/store Store,Querier \
> internal/store/mock_store.goAdd a //go:generate mockgen ... directive so go generate ./... keeps mocks in sync (generated mocks go stale when the interface changes - run it in CI to catch drift). The -typed flag emits type-safe Return/Do/DoAndReturn helpers.
Test with gomock
Per pkg.go.dev/go.uber.org/mock/gomock (opens in new window):
func TestOrderService_Submit(t *testing.T) {
ctrl := gomock.NewController(t)
// ctrl.Finish() runs automatically via t.Cleanup when *testing.T is passed.
mockStore := store.NewMockStore(ctrl)
mockStore.EXPECT().
SaveOrder(gomock.Any()).
Return(nil).
Times(1)
svc := NewOrderService(mockStore)
if err := svc.Submit(Order{ID: "abc"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}Matchers, counts, ordering
| Matcher | Behaviour |
|---|---|
gomock.Any() | Any argument value |
gomock.Eq(v) | Deep equality |
gomock.Nil() / gomock.Not(m) | Nil / negation |
gomock.AssignableToTypeOf(v) | Type-assignability |
gomock.InAnyOrder(s) | Slice elements in any order |
gomock.Regex(re) | String matches regexp |
mockStore.EXPECT().FindByID(gomock.Any()).Return(nil, ErrNotFound).Times(2)
mockStore.EXPECT().Ping().MinTimes(1).MaxTimes(3)
mockStore.EXPECT().Metrics().AnyTimes()
gomock.InOrder(
mockStore.EXPECT().Begin(),
mockStore.EXPECT().SaveOrder(gomock.Any()).Return(nil),
mockStore.EXPECT().Commit(),
)testify/mock (github.com/stretchr/testify)
Per github.com/stretchr/testify (opens in new window) and pkg.go.dev/github.com/stretchr/testify/mock (opens in new window) - embed mock.Mock and implement the interface by hand:
type MockNotifier struct {
mock.Mock
}
func (m *MockNotifier) Send(to, body string) error {
args := m.Called(to, body)
return args.Error(0)
}
func TestAlertService_Notify(t *testing.T) {
n := new(MockNotifier)
n.On("Send", "ops@example.com", mock.Anything).Return(nil)
svc := NewAlertService(n)
if err := svc.Notify("ops@example.com", "disk full"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
n.AssertExpectations(t) // every On(...) expectation was exercised
}mock.Anything ≈ gomock.Any(). Also: n.AssertCalled(t, "Send", ...) / n.AssertNotCalled(t, "Send").
Choosing between them
| Concern | gomock | testify/mock |
|---|---|---|
| Mock generation | mockgen codegen | Hand-written |
| Argument matching | Rich matcher library | mock.Anything + basic |
| Ordering | InOrder/After | Not built-in |
| Dependency | Two packages | One package |
gomock when strict call-order or exhaustive matching matters; testify/mock when the team already uses testify/assert and wants one dependency (hand-written stubs must be updated manually on interface change).
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Mock every dependency | Tests verify mock wiring, not behavior | Mock only true isolation boundaries |
Forget AssertExpectations (testify) | Uncalled On(...) passes silently | Always call it at the end |
Manual ctrl.Finish() (gomock) | Redundant with NewController(t) | Remove |
AnyTimes() everywhere | Hides missing invocations | Default to Times(1) / MinTimes(1) |