The mental model behind gotest. Learn the conventions once — the rest follows.
Test suites are behavioral specifications. Every level of the hierarchy maps to a concept.
| Go construct | Spec concept | Example |
|---|---|---|
struct | Subject | UserServiceTestSuite |
method | Capability | TestCreate |
When() | Context | "when email is valid" |
It() | Behavior | "creates the user" |
Each() | Variants | "standard format", "missing @" |
Naming conventions at the struct and method level, plus string descriptions in When and It, form a complete specification. The tool generates the wiring and can render the full spec in human-readable form:
This isn't a separate reporting layer — it's the same test hierarchy rendered differently. The spec view and the test output always agree because they come from the same source.
The naming conventions are the entire API. There are no config files, struct tags, or annotations.
| Pattern | Meaning |
|---|---|
*TestSuite | Test suite. Each generates a func Test* entry point. |
*Fixture | Package fixture. Shared setup for all suites in a package. |
*SharedFixture | Cross-package fixture. Runs in a subprocess, state transfers via JSON. |
| Method | Meaning |
|---|---|
Test* | Test case. Becomes a subtest. |
BeforeAll | Runs once before all tests in the suite. |
AfterAll | Runs once after all tests. Registered as cleanup, so it always runs. |
BeforeEach | Runs before every test case. |
AfterEach | Runs after every test case. Deferred, so it runs even on fatal errors. |
SuiteGuard() | Returns a reason to skip the suite, or empty string to run. Evaluated at runtime. |
SuiteConfig() | Returns timeout, parallelism, and fail-fast settings. |
FixtureConfig() | Returns timeout and retry settings for package fixtures. |
SharedFixtureConfig() | Returns timeout and retry settings for shared fixtures. |
Hydrate / Dehydrate | Reconstruct / clean up local resources in shared fixtures after state transfer. |
| Prefix | Effect | Use case |
|---|---|---|
F_ | Focus — only focused items run | Tight iteration during development |
X_ | Exclude — always skipped | Temporarily disable a flaky test |
F_ and X_ work on both types and methods. X_ takes precedence over F_. Use --ci to fail the build if any focus prefix is committed and to enable snapshot read-only mode. CI mode is auto-detected from the CI environment variable.
Predictable execution order with guaranteed cleanup — even on panics and fatal errors.
All hooks are optional. Unimplemented hooks are no-ops. AfterAll is registered before BeforeAll runs, so cleanup happens even if setup fails. AfterEach is deferred, so it runs even on t.Fatal().
Every hook accepts either *gotest.T (full DSL access) or *testing.T (plain stdlib). Mix freely within the same suite:
func (s *MySuite) BeforeAll(t *gotest.T) {} // full DSL func (s *MySuite) BeforeEach(t *testing.T) {} // plain stdlib func (s *MySuite) TestPlain(t *testing.T) {} // no gotest import needed func (s *MySuite) TestRich(t *gotest.T) {} // When, It, MatchSnapshot
When BeforeEach returns a value, each test receives its own isolated context. This enables safe method-level parallelism without shared mutable state:
func (s *MySuite) BeforeEach(t *gotest.T) *TestCtx { return &TestCtx{conn: s.pool.Acquire()} } func (s *MySuite) AfterEach(t *gotest.T, ctx *TestCtx) { ctx.conn.Release() } func (s *MySuite) TestCreate(t *gotest.T, ctx *TestCtx) { // ctx.conn is unique to this test }
Suite-level: Each suite runs as a separate subprocess — full process isolation with zero shared state between suites. This is automatic.
Method-level: Opt-in via SuiteConfig{Parallel: true}. Requires a returning BeforeEach so each parallel test gets its own state.
Methods on *gotest.T for structuring tests. Every method maps to t.Run — the DSL adds semantics, not runtime machinery.
When groups context. It specifies behavior. Nest them freely to build a specification tree:
func (s *MySuite) TestCreate(t *gotest.T) { t.When("email is valid", func(w *gotest.T) { w.It("creates the user", func(it *gotest.T) { err := s.svc.Create(ctx, validUser) gotest.NoError(it, err) }) }) t.When("email already exists", func(w *gotest.T) { w.It("returns ErrDuplicate", func(it *gotest.T) { s.svc.Create(ctx, validUser) err := s.svc.Create(ctx, validUser) gotest.ErrorIs(it, err, ErrDuplicate) }) }) }
Iterator API — range over test entries with compile-time type safety:
for it, tc := range gotest.Each(t, []struct { Desc string Input string Want int }{ {Desc: "single digit", Input: "5", Want: 5}, {Desc: "negative", Input: "-3", Want: -3}, }) { gotest.Equal(it, tc.Want, parse(tc.Input)) }
Desc or Name field for the test name, falls back to #0, #1, etc.
Capture expected output once, verify it on every subsequent run:
gotest.MatchSnapshot(t, render(input)) // auto-named from test path gotest.MatchSnapshot(t, render(other), "variant") // explicit snapshot name
Snapshots are stored in testdata/__snapshots__/ next to the test file. On first run, the snapshot is created. On subsequent runs, the output is compared and failures include a diff. Snapshot entries are written in deterministic order and are thread-safe — parallel test methods can use MatchSnapshot without coordination.
Update all snapshots with gotest --update-snapshots ./... or GOTEST_UPDATE_SNAPSHOTS=1 go test ./... when running go test directly.
Package-level functions for polling conditions. Callbacks receive *gotest.R — an assertion recorder that captures failures without propagating them:
gotest.Eventually(t, 5*time.Second, 100*time.Millisecond, func(poll *gotest.R) { result, err := s.store.Get("key") gotest.NoError(poll, err) gotest.Equal(poll, "completed", result.Status) })
gotest.Consistently(t, 2*time.Second, 100*time.Millisecond, func(poll *gotest.R) {
gotest.True(poll, s.cache.IsValid())
})
On each tick the callback runs; if any assertion fails, it retries on the next tick. On timeout, the last failure is reported. Consistently inverts the logic — it fails on the first assertion failure.
| Method | Description |
|---|---|
t.T() | Returns the underlying *testing.T. Use when you need to pass it to third-party libraries. |
t.Context() | Returns the test context. Cancelled when the test ends, carries the test deadline. |
Expensive setup — databases, containers, external services — defined once, shared across suites.
Any struct ending in Fixture is a package fixture. Suites reference it via a named pointer field:
type E2EFixture struct { Pool *pgxpool.Pool } func (f *E2EFixture) BeforeAll(ctx context.Context) error { // start database, populate f.Pool } type BatchTestSuite struct { Fixture *E2EFixture // automatically wired }
Fixture hooks use (ctx context.Context) error signatures. Errors are reported with automatic attribution — E2EFixture.BeforeAll failed: connection refused.
Package fixtures support all four lifecycle hooks. BeforeEach/AfterEach wrap every individual test case, running outside the suite's own hooks:
Fixtures compose through named pointer fields — the same pattern suites use to reference fixtures. A fixture can depend on multiple other fixtures. The generator resolves dependencies and wires the lifecycle automatically:
type DBFixture struct { Pool *pgxpool.Pool } func (f *DBFixture) BeforeAll(ctx context.Context) error { // start database container, populate f.Pool } type CacheFixture struct { Client *redis.Client } func (f *CacheFixture) BeforeAll(ctx context.Context) error { // start Redis container, populate f.Client } type APIFixture struct { DB *DBFixture // dependency — wired automatically Cache *CacheFixture // dependency — wired automatically ServerURL string } func (f *APIFixture) BeforeAll(ctx context.Context) error { // start API server using f.DB.Pool and f.Cache.Client } type OrderTestSuite struct { Fixture *APIFixture // accesses DB, Cache, and ServerURL }
Dependencies are set up first. Independent fixtures set up in parallel; teardown runs in reverse:
Structs ending in SharedFixture run in a subprocess and share state across packages via JSON serialization. Resources that can't serialize (connection pools, caches) are reconstructed in each test process via Hydrate:
type PostgresSharedFixture struct { ConnStr string // serialized — transfers across processes Pool *pgxpool.Pool // local — reconstructed via Hydrate } func (f *PostgresSharedFixture) BeforeAll(ctx context.Context) error { f.ConnStr = startPostgres(ctx) return f.connect(ctx) } func (f *PostgresSharedFixture) Hydrate(ctx context.Context) error { return f.connect(ctx) } func (f *PostgresSharedFixture) Dehydrate(ctx context.Context) error { f.Pool.Close(); return nil } func (f *PostgresSharedFixture) connect(ctx context.Context) error { var err error f.Pool, err = pgxpool.New(ctx, f.ConnStr) return err }
BeforeAll always sets transfer fields. Local fields only need to be set in BeforeAll when a dependent shared fixture accesses them in the DAG — otherwise they may add an idle resource to the subprocess. Hydrate handles local field reconstruction in test processes.
Hydrate are automatically classified as local and excluded from serialization. Everything else transfers. No annotations needed.
SharedFixtures can depend on other SharedFixtures via pointer fields — the same pattern used by package fixtures. BeforeAll runs in dependency order (parents first, independent fixtures in parallel). Cyclic dependencies are rejected at resolution time.
type SchemaSharedFixture struct { Postgres *PostgresSharedFixture // dependency — Postgres starts first Version string } func (f *SchemaSharedFixture) BeforeAll(ctx context.Context) error { // f.Postgres.ConnStr is available return migrate(f.Postgres.ConnStr) }
B.BeforeAll() can access all of A's fields, including local fields like connection pools — provided A.BeforeAll() set them. When a dependent only needs transfer fields, skipping local setup in the parent avoids idle resources in the subprocess.
Suites are dispatched as soon as their specific shared fixture dependencies are ready — they don't wait for unrelated fixtures. Transitive dependencies are included automatically: if a suite needs SchemaSharedFixture, it also gets PostgresSharedFixture.
Type-safe generics with compile-time checking. Zero external dependencies. Works with both *gotest.T and *testing.T.
t.Helper() — the framework resolves the outermost caller from the test file.
| Function | Description |
|---|---|
Equal(t, expected, actual) | Deep equality. Cross-type comparison is a compile error. Failures include a diff. |
NotEqual(t, a, b) | Inverse of Equal. |
Zero(t, value) | Value equals its zero value. |
NotZero(t, value) | Value is not zero. |
Empty(t, obj) | Slice, map, string, or channel is empty. |
NotEmpty(t, obj) | Not empty. |
| Function | Description |
|---|---|
NoError(t, err) | Error is nil. |
Error(t, err) | Error is not nil. |
ErrorIs(t, err, target) | Wraps errors.Is. |
ErrorAs[E](t, err) | Wraps errors.As. Returns the matched error. |
ErrorContains(t, err, substr) | Error message contains substring. |
| Function | Description |
|---|---|
Contains(t, haystack, needle) | String, slice, or map contains value. |
NotContains(t, s, v) | Inverse of Contains. |
Len(t, obj, n) | Collection has exactly n elements. |
ElementsMatch(t, a, b) | Same elements regardless of order. |
Subset(t, list, sub) | All elements of sub exist in list. |
| Function | Description |
|---|---|
Greater(t, a, b) | a > b. Constrained to cmp.Ordered — comparing incomparable types is a compile error. |
GreaterOrEqual(t, a, b) | a ≥ b. |
Less(t, a, b) | a < b. |
LessOrEqual(t, a, b) | a ≤ b. |
InDelta(t, expected, actual, delta) | Difference within delta. Works with any numeric type. |
| Function | Description |
|---|---|
Regexp(t, pattern, str) | String matches regex pattern. |
JSONEq(t, expected, actual) | JSON-equal. Accepts string, []byte, io.Reader, or any marshalable value. |
TimeWithin(t, expected, actual, tol) | Times within tolerance. |
TimeIsNow(t, ts, tolerance) | Timestamp is approximately now. |
True(t, v) / False(t, v) | Boolean checks. |
Panics(t, fn) | Function panics. Returns the recovered value. |
Fail(t, msgAndArgs...) | Explicit immediate failure. |
Must(val, ok) | Unwraps (T, error) and (T, bool) pairs. Panics on error or false — for test setup, not assertions. |
| Function | Description |
|---|---|
Eventually(t, timeout, tick, fn) | Poll func(poll *R) until assertions pass or timeout. |
Consistently(t, duration, tick, fn) | Assert func(poll *R) holds for the full duration. |
poll (a *gotest.R) exactly as you would use t. Failures are collected, not propagated, enabling retry semantics.
t.Helper(), so failures report the caller's file and line — not the assertion library internals.
Sensible defaults. Override only what you need — via project file, marker methods, or CLI flags.
There are two independent configuration scopes:
Project scope — CLI flags override the project file. These control the test runner itself:
Code scope — marker methods override built-in presets. These control individual suites and fixtures:
The two scopes are independent — they don't override each other. For shared fixture timeouts specifically, --setup-timeout is an outer wall-clock budget on the entire setup phase, while SharedFixtureConfig().Timeout is an inner per-fixture deadline. Both run concurrently — whichever fires first wins. When --setup-timeout is omitted, only the per-fixture timeouts apply.
.gotest.ymlPlace a .gotest.yml in your project root. gotest finds it by walking up from the working directory, stopping at the first match or at a go.mod boundary. CLI flags always take precedence; zero or omitted fields use defaults.
| Field | Type | Default | CLI override | Effect |
|---|---|---|---|---|
tags | string | none | -tags | Build tags, comma-separated (e.g. "integration,e2e"). |
setup-timeout | duration | none | --setup-timeout | Total budget for all shared fixture setup. Omit to let each fixture's own config govern. Negative disables. |
min-coverage | int | 0 (off) | --min | Minimum coverage percentage, 0–100. |
parallel | int | 0 (auto) | --parallel | Total concurrent test method budget. 0 means 2×GOMAXPROCS. |
debounce | duration | 200ms | --debounce | Watch mode re-run delay. |
lint.skip | [string] | none | — | Lint rules to disable: stdlib-test, testify. |
tags: integration
setup-timeout: 2m
min-coverage: 80
parallel: 12
debounce: 500ms
lint:
skip:
- testify
| Field | Default | Effect |
|---|---|---|
Timeout | 30s | Per-test-case deadline. |
SetupTimeout | 30s | BeforeAll / AfterAll deadline. |
Retries | 0 | Per-test-case retry attempts on failure. |
FailFast | false | Stop the suite on first failure. |
Parallel | false | Run test methods concurrently. |
| Field | Default | Effect |
|---|---|---|
Timeout | 2 min | BeforeAll / AfterAll deadline. |
Retries | 0 | BeforeAll retry attempts. |
RetryDelay | 0 | Pause between retries. |
Start with a preset, override individual fields:
| Preset | Timeout | SetupTimeout | Retries | Use case |
|---|---|---|---|---|
DefaultSuiteConfig() | 30s | 30s | 0 | Unit and integration tests |
IntegrationSuiteConfig() | 2 min | 5 min | 0 | Heavier integration tests |
DefaultFixtureConfig() | 2 min | — | 0 | Standard fixtures |
ContainerFixtureConfig() | 5 min | — | 1 | Testcontainers, image pulls |
Timeout: -1) to explicitly disable a timeout. Zero means "keep the default."
Statement-weighted coverage from the Go coverage profile. One source of truth, no heuristics.
The Go compiler instruments code into basic blocks. Each block records how many statements it contains and how many times it was executed. Coverage at any scope — file, directory, workspace — is:
A directory's percentage is the weighted sum of its children, weighted by statement count. This means a parent's number is always derivable from its children — no rounding surprises, no averaging artifacts.
Coverage percentage answers: "How well-tested is the code my tests reach?"
The breadth indicator answers a complementary question: "How much of my codebase do my tests reach at all?" It shows profiled source files vs. total source files per directory. A file at 0% still counts as reached — it was instrumented.
Test-only packages (no production code) run with -coverpkg=./..., which instruments the entire module. These cross-package profiles are supplementary — they can increase coverage for files already in scope, but don't expand the file scope. This prevents integration test packages from inflating coverage numbers for code they touch incidentally.
numStatements as the sole metric. No line counting, no token scanning, no filesystem heuristics. The profile is the source of truth.
One command, multiple modes. All go test flags pass through unchanged.
| Command | Effect |
|---|---|
gotest ./... | Generate overlays and run tests. The default workflow. |
gotest watch ./... | Re-run on file changes. Only affected packages re-run. |
gotest spec ./... | Run tests and render the behavioral specification. |
gotest summary ./... | Failure-focused summary for CI. Shows only failing tests with assertion output. |
gotest lint ./... | Static analysis for test suites. |
gotest scaffold ./pkg/user.Svc | Generate a suite skeleton from any Go type. |
gotest migrate ./... | Convert testify/suite tests automatically. |
gotest generate ./... | Generate suite files without running tests. |
gotest clean ./... | Remove cached overlays (debugging). |
gotest refactor toggle-focus <file> <id> | Toggle F_/X_ prefixes programmatically. |
| Flag | Effect |
|---|---|
--ci | CI mode: fail on focus prefixes, snapshot read-only. Auto-detected from CI env var; opt out with GOTEST_CI=0. |
--spec | Append spec summary after normal test output. |
--update-snapshots | Regenerate all snapshot files. |
--no-cache | Disable overlay cache, force fresh generation. |
--min=<pct> | Fail if coverage falls below threshold. |
--format=<fmt> | Output format for spec command: md (Markdown) or json (structured JSON). |
--output=<path> | Write formatted output to file. |
--no-color | Strip ANSI codes from output. |
--debug | Preserve overlays after the run for inspection. |
--setup-timeout=<dur> | Total budget for all shared fixture setup. Omit to let each fixture's own config govern. Use a negative value (e.g. -1s) to disable. |
--debounce=<dur> | Watch mode debounce interval (default 200ms). |
--input=<path> | Read test output from file instead of running tests (spec, summary). |
--github | GitHub CI mode: emit ::error annotations and write step summary (summary command). Auto-detected via $GITHUB_ACTIONS. |
--coverage=<file> | Include coverage from profile in summary output (summary command). |
go test flags work unchanged: -v, -race, -cover, -count, -run, -json, -short, -timeout.
Failure-focused summaries, inline PR annotations, and coverage reports — out of the box.
The summary subcommand shows only failing tests with their assertion output, filtering out === RUN, === CONT, --- PASS noise. When all tests pass, it prints a single success line:
147 tests passed (2.3s) Coverage: 82.4%
3 of 147 tests failed
FAIL pkg/foo TestValidateInput / empty string (12ms)
foo_test.go:42: expected error, got nil
FAIL pkg/bar TestProcessOrder / concurrent writes (1.2s)
bar_test.go:88:
expected: []string{"a", "b", "c"}
got: []string{"a", "c", "b"}
In GitHub Actions, --github (auto-detected via $GITHUB_ACTIONS) emits ::error annotations that appear inline on PR diffs and writes a markdown summary to the job summary panel.
The repository includes a composite action at mvrahden/go-test@v1 that wraps gotest summary --github:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: mvrahden/go-test@v1
with:
packages: ./...
race: true
coverage: true
min-coverage: 80
| Input | Default | Description |
|---|---|---|
packages | ./... | Package patterns to test. |
race | false | Enable the race detector. |
coverage | false | Enable coverage profiling and reporting. |
min-coverage | none | Minimum coverage percentage (0–100). Fails the step if below. |
flags | none | Additional gotest flags (--double-dash style). |
go-test-flags | none | Additional go test flags (-single-dash style). |
version | gomod | Version to install, or gomod to resolve from go.mod. |
| Output | Description |
|---|---|
exit-code | Test process exit code. |
coverage | Coverage percentage (empty if coverage not enabled). |
By default (version: gomod), the action runs go run github.com/mvrahden/go-test/cmd/gotest, which resolves the version pinned in your project's go.mod. This keeps the CLI version in sync with the library you already depend on — no version drift between CI and local development.
Set version: latest or a specific tag (e.g. v1.2.0) to install a standalone binary via go install instead. This is useful when your project doesn't depend on go-test as a library.
gomod default, ensure gotest is in your go.mod: use a tool directive (Go 1.24+) or add it via go get github.com/mvrahden/go-test/cmd/gotest.
For non-GitHub CI systems, run gotest summary directly:
gotest summary ./... -race -coverprofile=coverage.out
Or pipe from go test -json to summarize existing output without re-running tests:
go test -json ./... | gotest summary --input=-