Rules-only version. Captures the enforceable testing conventions that coding:go-test-quality-assistant, coding:go-test-writer-assistant, and coding:go-test-coverage-assistant check during /coding:pr-review. For deeper background (database patterns, JSON/serialization, integration setups, full Ginkgo/Gomega API), consult the official documentation linked at the end of this file.
Built on Ginkgo v2 (BDD) + Gomega (matchers) + Counterfeiter (mocks).
Key principles:
- BDD: tests describe behavior, not implementation.
- Tests are independent and idempotent.
- Mocks at interface boundaries; real implementations for internal utilities.
- All time handling uses UTC (
time.Local = time.UTCin suite setup).
Owner: go-test-quality-assistant
Applies when: a Go test file uses t.Run inside a for ... range loop instead of DescribeTable/Entry. The rule fires regardless of whether a Ginkgo suite exists in the package — stdlib table-driven tests are discouraged universally; suite-coexistence amplifies the cost.
Enforcement: rules/go/no-stdlib-table-tests.yml
Why: Mixed Ginkgo + stdlib tables produce inconsistent reporter output, fragmented runs, and surprises with --focus / --label-filter. Single-framework enforcement keeps test runs predictable.
// stdlib table-driven test in a Ginkgo-suite package
func TestFoo(t *testing.T) {
tests := []struct{ input, want string }{ ... }
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { ... })
}
}// Ginkgo DescribeTable
var _ = DescribeTable("foo",
func(input, expected string) {
Expect(foo(input)).To(Equal(expected))
},
Entry("case A", "in1", "out1"),
Entry("case B", "in2", "out2"),
)Owner: go-test-quality-assistant
Applies when: a Go file in a package that has a Ginkgo TestSuite entry-point uses *testing.T directly inside test functions other than the suite entry-point itself.
Enforcement: rules/go/no-testing-t-direct.yml flags every func TestXxx(t *testing.T) declaration. The agent decides per-finding whether the function is the legitimate Ginkgo suite entry-point (RegisterFailHandler(Fail); RunSpecs(t, "Suite")) or a stdlib-style test that needs to be ported to Ginkgo — that distinction needs reading the function body's intent, which ast-grep can't do reliably.
Why: Direct testing.T use bypasses the Ginkgo lifecycle (BeforeEach/AfterEach/JustBeforeEach), produces flaky setup ordering, and breaks --focus filtering. Use Describe/Context/It/DescribeTable/Entry so the suite runs as one coherent test plan.
func TestUserService(t *testing.T) { // direct testing.T in a Ginkgo-suite package
t.Run("Create", func(t *testing.T) {
// ...
})
}var _ = Describe("UserService", func() {
Context("Create", func() {
It("creates a user with valid data", func() {
// ...
})
})
})Owner: go-test-quality-assistant
Applies when: a Go test file inside an It / BeforeEach / JustBeforeEach / AfterEach block calls an error-returning function whose return value is discarded.
Enforcement: rules/go/no-bare-error-call.yml (mechanical first-pass — flags bare method/selector call expression_statements inside Ginkgo It/BeforeEach/JustBeforeEach/AfterEach blocks) + judgment-tier LLM adjudication for void functions whose return is legitimately ignored
Why: errcheck (run by make precommit) flags discarded errors and breaks the build. Wrapping every error-returning call in a Gomega matcher (Succeed() / HaveOccurred() / MatchError(...)) documents the test's intent at the assertion site instead of relying on silent fall-through.
Matcher choice by intent:
- Expecting success:
Expect(fn(ctx)).To(Succeed()) - Expecting failure:
Expect(fn(ctx)).To(HaveOccurred()) - Need the error for further assertions:
err := fn(ctx); Expect(err).To(MatchError(...))
// errcheck: "Error return value not checked"
It("calls Save exactly twice", func() {
service.Process(ctx)
Expect(store.SaveCallCount()).To(Equal(2))
})It("calls Save exactly twice", func() {
Expect(service.Process(ctx)).To(HaveOccurred())
Expect(store.SaveCallCount()).To(Equal(2))
})Owner: go-test-quality-assistant
Applies when: a Go package contains test files (*_test.go) but no *_suite_test.go file with a TestSuite entry-point and RunSpecs.
Enforcement: rules/go/suite-test-file-required.yml (mechanical first-pass — flags every top-level var _ = Describe(...) anchor in a test file) + judgment-tier LLM adjudication for whether a *_suite_test.go with RunSpecs exists in the same package directory
Why: Without a suite file, Ginkgo specs are not discovered. make test exits 0 even though no specs ran — silent coverage loss. The suite file is the single entry-point Go's testing package invokes.
// Copyright (c) 2026 Benjamin Borbe All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package pkg_test
import (
"testing"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/format"
)
//go:generate go run -mod=mod github.com/maxbrunsfeld/counterfeiter/v6 -generate
func TestSuite(t *testing.T) {
time.Local = time.UTC
format.TruncatedDiff = false
RegisterFailHandler(Fail)
suiteConfig, reporterConfig := GinkgoConfiguration()
suiteConfig.Timeout = 60 * time.Second
RunSpecs(t, "Test Suite", suiteConfig, reporterConfig)
}Requirements:
- External test package:
package_testsuffix (e.g.pkg_test,user_test) — keeps tests honest about exported surface. - UTC timezone:
time.Local = time.UTC— eliminates locale flakiness. - Full diffs:
format.TruncatedDiff = false— never hide assertion failures. - Suite timeout:
suiteConfig.Timeout— safety net against hanging tests. //go:generate: enablesgo generate ./...for Counterfeiter mocks.
Owner: go-test-quality-assistant
Applies when: a Go binary project (package main with main.go) does not have a main_test.go containing a Compiles It-block backed by gexec.Build.
Enforcement: rules/go/main-test-with-compiles.yml (mechanical first-pass — flags func main() declarations in package main files) + judgment-tier LLM adjudication for whether main_test.go with a gexec.Build Compiles It-block exists in the same directory
Why: Without main_test.go + a Compiles check, build failures in main.go are not caught by make test. The CI greenlight then deploys a binary that doesn't link.
// Copyright (c) 2026 Benjamin Borbe All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:generate go run -mod=mod github.com/maxbrunsfeld/counterfeiter/v6 -generate
package main_test
import (
"testing"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/format"
"github.com/onsi/gomega/gexec"
)
var _ = Describe("Main", func() {
It("Compiles", func() {
var err error
_, err = gexec.Build(".", "-mod=mod", "-buildvcs=false")
Expect(err).NotTo(HaveOccurred())
})
})
func TestSuite(t *testing.T) {
time.Local = time.UTC
format.TruncatedDiff = false
RegisterFailHandler(Fail)
suiteConfig, reporterConfig := GinkgoConfiguration()
suiteConfig.Timeout = 60 * time.Second
RunSpecs(t, "Main Suite", suiteConfig, reporterConfig)
}The Compiles test verifies buildability via gexec.Build with -buildvcs=false. Main is the only exception to the standard pattern — all other packages (internal, pkg, cmd subpackages) use the standard form.
Tests follow Arrange / Act / Assert via Ginkgo lifecycle hooks:
BeforeEach— setup before eachIt(Arrange)JustBeforeEach— action just before eachIt, after allBeforeEach(Act)AfterEach— cleanup after eachItContext— groups related scenariosIt— individual assertions
var _ = Describe("Product", func() {
var ctx context.Context
var product domain.Product
var result domain.Price
BeforeEach(func() {
ctx = context.Background()
product = domain.Product{Name: "Example", Price: 99.99}
})
Context("CalculateDiscount", func() {
JustBeforeEach(func() {
result = product.CalculateDiscount(0.1)
})
It("returns correct discounted price", func() {
Expect(result).To(Equal(domain.Price(89.99)))
})
})
})Owner: go-test-quality-assistant
Applies when: a *_suite_test.go file calls GinkgoConfiguration() without setting suiteConfig.Timeout before RunSpecs.
Enforcement: rules/go/suite-timeout-required.yml — flags TestXxx(t *testing.T) bodies calling GinkgoConfiguration() without an assignment to suiteConfig.Timeout; agent dismisses helper-set, decorator-set, and aliased-import (g.GinkgoConfiguration()) cases.
Why: Without a suite-level timeout, a hung test holds the test runner indefinitely. CI eventually kills the job — but only after the job-level timeout (often 30+ minutes), wasting CI minutes and delaying feedback. Suite-level timeout is the safety net that fails fast.
Per-spec timeout:
It("does something within 2s", func(ctx context.Context) {
// test body
}, SpecTimeout(2*time.Second))Per-node timeout for Describe/Context/It:
Describe("slow subsystem", func() {
It("completes within 5s", func(ctx SpecContext) {
Eventually(ctx, func() bool { return ready }).Should(BeTrue())
}, NodeTimeout(5*time.Second))
})Owner: go-test-quality-assistant
Applies when: a test file declares a hand-written struct that satisfies a production interface and is used in place of a real implementation under test, instead of importing a mocks/<Name> fake produced by Counterfeiter.
Enforcement: rules/go/counterfeiter-mocks-required.yml (mechanical first-pass — flags every interface type declaration in production code) + judgment-tier LLM adjudication for whether a //counterfeiter:generate directive exists above the interface and a corresponding mocks/*.go file is present
Why: Hand-written mocks drift from the interface — when the production interface gains a method, the hand-written mock silently keeps satisfying the old surface (test still compiles, doesn't exercise the new contract). Counterfeiter-generated fakes regenerate from the interface, so any drift surfaces at go generate time.
Place a //counterfeiter:generate line above each interface that needs a mock, then go generate ./... produces the fake.
//counterfeiter:generate -o mocks/user-service.go --fake-name UserService . Service
type Service interface {
Create(ctx context.Context, user User) error
Get(ctx context.Context, id string) (*User, error)
}Conventions:
-o mocks/<file>.go— mocks live in amocks/subpackage, never alongside production code.--fake-name <Name>— drop theFakeprefix; Counterfeiter's defaultFakeServicebecomesUserServicefor cleaner test code.- One mock per file — never bundle multiple fakes in one generated file.
mockService := &mocks.UserService{}
mockService.CreateReturns(nil)
err := caller.DoSomething(ctx, mockService)
Expect(err).To(Succeed())
// Verify call count + args
Expect(mockService.CreateCallCount()).To(Equal(1))
actualCtx, actualUser := mockService.CreateArgsForCall(0)
Expect(actualCtx).To(Equal(ctx))
Expect(actualUser.Name).To(Equal("test"))Owner: go-test-quality-assistant
Applies when: a Go business-logic file (outside main.go, cmd/**, *_test.go, vendor/) reads the current time. Tests cannot control time.Now() directly, so dependent code is unverifiable.
Enforcement: cross-rule — overlaps with go-time/no-time-now-direct (already in rules/index.json). This rule scopes the same constraint to test-coverage assessments: a service that doesn't inject time has no testable time-dependent paths.
Trigger: **/*.go
Why: Without libtime.CurrentDateTimeGetter injection, every test that depends on time becomes flaky or impossible. libtime.NewCurrentDateTime() + SetNow(fixedTime) produces deterministic, fast tests for date math, expiry windows, scheduling, etc.
import libtime "github.com/bborbe/time"
var _ = Describe("Service", func() {
var currentDateTime libtime.CurrentDateTime
var service Service
var fixedTime time.Time
BeforeEach(func() {
fixedTime = time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
currentDateTime = libtime.NewCurrentDateTime()
currentDateTime.SetNow(fixedTime)
service = NewService(currentDateTime)
})
It("uses the injected time", func() {
Expect(service.GetTimestamp()).To(Equal(fixedTime))
})
})Helpers:
import libtimetest "github.com/bborbe/time/test"
fixedTime := libtimetest.ParseDateTime("2026-06-02T00:00:00Z")
Expect(actualTime).To(BeTemporally("~", expectedTime, time.Second))
Expect(actualTime).To(BeTemporally(">", beforeTime))
Expect(actualTime).To(BeTemporally("<=", afterTime))Prefer matchers that document intent over manual err != nil checks.
| Use case | Matcher |
|---|---|
| Function should succeed | Expect(fn()).To(Succeed()) |
| Function should fail | Expect(fn()).To(HaveOccurred()) |
| Specific error value | Expect(err).To(MatchError(target)) |
| Substring in message | Expect(err).To(MatchError(ContainSubstring("not found"))) |
| Specific error type | var target *MyErr; Expect(errors.As(err, &target)).To(BeTrue()) |
Context("Validate", func() {
var err error
JustBeforeEach(func() {
err = service.Validate(ctx, input)
})
Context("valid input", func() {
It("returns no error", func() {
Expect(err).To(Succeed())
})
})
Context("empty value", func() {
BeforeEach(func() {
input.Value = ""
})
It("returns validation error", func() {
Expect(err).To(MatchError(ContainSubstring("value cannot be empty")))
})
})
})Error-type assertions:
It("returns NotFoundError for missing items", func() {
_, err := service.Get(ctx, "nonexistent")
Expect(err).To(MatchError(&NotFoundError{}))
})
It("returns ValidationError for invalid data", func() {
err := service.Create(ctx, InvalidData{})
var validationErr *ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Field).To(Equal("name"))
})File naming conventions:
- Test files:
feature_test.go(kebab-case:user-service_test.go) - Suite file:
<pkg>_suite_test.go(e.g.pkg_suite_test.go) - Package:
<pkg>_test— external test package, separate from implementation
Test naming pattern — descriptive hierarchy that reads as a sentence:
var _ = Describe("UserService", func() {
Context("Create", func() {
Context("with valid data", func() {
It("creates user successfully", func() { ... })
})
Context("with invalid email", func() {
It("returns validation error", func() { ... })
})
})
})Directory layout:
pkg/
├── user-service.go
├── user-service_test.go
├── pkg_suite_test.go
└── mocks/
├── user-repository.go
└── email-service.go
var _ = Describe("UnitConverter", func() {
var converter UnitConverter
BeforeEach(func() {
converter = NewUnitConverter()
})
DescribeTable("unit conversions",
func(from, to string, value, expected float64) {
result, err := converter.Convert(from, to, value)
Expect(err).To(Succeed())
Expect(result).To(BeNumerically("~", expected, 0.001))
},
Entry("meters to feet", "m", "ft", 1.0, 3.281),
Entry("feet to meters", "ft", "m", 3.281, 1.0),
Entry("celsius to fahrenheit", "C", "F", 0.0, 32.0),
)
})Use slices.Contains instead of manual loops — the slicescontains linter enforces this:
// BAD — linter will reject
func contains(s []string, v string) bool {
for _, item := range s {
if item == v {
return true
}
}
return false
}
// GOOD
import "slices"
slices.Contains(s, v)- Clear hierarchy —
Describe(unit under test) →Context(scenario) →It(single assertion or tightly coupled set). - Verify both behavior and calls — assert return value plus mock call count + args for the right side of the contract.
- Independent tests — fresh setup in
BeforeEach; never share state acrossItblocks via package-level vars. - Comprehensive error paths — happy path + every distinct failure mode (invalid input, dependency error, timeout) gets its own
Context.
- Testing implementation details — assert behavior (return value, observable side effect), not which helper method was called internally.
- Large, unfocused
Itblocks — oneItper behavior; if a test exercises create + update + delete, split into threeContexts. - Only happy-path coverage — every error-returning function needs a failure-path
Context. - Test data dependencies — never
var globalTestData TestDatashared across tests. Build fresh data inBeforeEach.
- Ginkgo v2 — BDD test framework reference.
- Gomega — matcher reference.
- Counterfeiter — mock generator.
github.com/bborbe/time— injectable time utilities.github.com/bborbe/errors— error wrapping utilities used in test assertions.