Testland
Browse all skills & agents

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-tests
View source

go-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

  1. stdlib testing is the idiomatic default - zero install, zero config, works wherever Go works.
  2. Ginkgo + Gomega when the project lives in the Kubernetes ecosystem (k8s.io/*, sigs.k8s.io/*, knative.dev/* in go.mod) or the team has an explicit BDD culture → references/ginkgo.md.
  3. Match the existing convention: Ginkgo in 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.
  4. Mocking across interface boundariesreferences/go-mocking.md (gomock codegen vs testify/mock hand-written stubs).

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 pattern

Step 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.txt

Step 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=30s

Failures 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-function

No 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

Step 8 - Build tags and helpers

//go:build integration

go 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.xml

Authoring conventions

When authoring a new unit test in an existing project:

  1. Detect the framework: default to stdlib 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.
  2. Placement: test files MUST end in _test.go and live in the same directory as the source (go-test-pkg (opens in new window)). Same-package = white-box (unexported access); package <name>_test = black-box.
  3. 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.
  4. One spec → one new 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.
  5. Refuse universally-quantified specs ("for all valid inputs") - property-based scope (qa-property-based plugin); Go's native fuzzing (Step 6) covers crash/invariant hunting on generated inputs.

Anti-patterns

Anti-patternWhy it failsFix
Table loop without t.RunFailures not individually named or filterableSubtests (Step 2)
Forget loop-variable capture pre-Go 1.22All subtests see the last iterationtt := tt (Step 3)
Skip -race in CIData races ship to prodAlways -race (Step 9)
t.Parallel() nowhereSlow suite at scaleMark parallel-safe tests (Step 3)
Multiple checks in one t.ErrorfFail-fast loses contextOne assertion per logical thing

Limitations

  • No assertion library in stdlib - testify is the common ecosystem addition; plain if got != want is idiomatic.
  • No fixture concept - use t.Cleanup + helper functions.
  • No parametrize beyond table-driven loops.
  • No mocking in stdlib - interfaces + hand-written fakes, or the codegen tools in references/go-mocking.md.

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 template

The 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-patternWhy it failsFix
Ginkgo for a non-BDD codebaseVerbose vs stdlib testingstdlib (SKILL.md)
Committed FDescribe / FItSuite runs only focused specs--no-focus in CI
Sleep-based async assertionsFlakyEventually / Consistently
Heavy nesting (5+ levels)Setup hard to reason aboutFlatten 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.

ToolApproach
go.uber.org/mock (gomock + mockgen)Codegen from interface
github.com/stretchr/testify/mockHand-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.go

Add 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

MatcherBehaviour
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.Anythinggomock.Any(). Also: n.AssertCalled(t, "Send", ...) / n.AssertNotCalled(t, "Send").

Choosing between them

Concerngomocktestify/mock
Mock generationmockgen codegenHand-written
Argument matchingRich matcher librarymock.Anything + basic
OrderingInOrder/AfterNot built-in
DependencyTwo packagesOne 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-patternWhy it failsFix
Mock every dependencyTests verify mock wiring, not behaviorMock only true isolation boundaries
Forget AssertExpectations (testify)Uncalled On(...) passes silentlyAlways call it at the end
Manual ctrl.Finish() (gomock)Redundant with NewController(t)Remove
AnyTimes() everywhereHides missing invocationsDefault to Times(1) / MinTimes(1)

References