Contents
  1. Conceptual Model — how Go constructs map to spec concepts
  2. Naming Conventions — suffixes, prefixes, and lifecycle methods
  3. Suite Lifecycle — execution order, hooks, and parallel modes
  4. Test DSL — When/It, data-driven tests, snapshots, polling
  5. Fixtures — package fixtures, DAG ordering, shared fixtures
  6. Assertions — type-safe generics for equality, errors, collections
  7. Configuration — project file, suite config, fixture config
  8. Coverage — statement-weighted calculation and thresholds
  9. CLI Reference — commands, flags, go test passthrough
  10. CI Integration — summary output, GitHub Action, PR annotations

Conceptual Model

Test suites are behavioral specifications. Every level of the hierarchy maps to a concept.

Go constructSpec conceptExample
structSubjectUserServiceTestSuite
methodCapabilityTestCreate
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:

UserService Create when email is valid creates the user sends a welcome email when email already exists returns ErrDuplicate Delete soft-deletes the user

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.

Naming Conventions

The naming conventions are the entire API. There are no config files, struct tags, or annotations.

Types

PatternMeaning
*TestSuiteTest suite. Each generates a func Test* entry point.
*FixturePackage fixture. Shared setup for all suites in a package.
*SharedFixtureCross-package fixture. Runs in a subprocess, state transfers via JSON.

Methods

MethodMeaning
Test*Test case. Becomes a subtest.
BeforeAllRuns once before all tests in the suite.
AfterAllRuns once after all tests. Registered as cleanup, so it always runs.
BeforeEachRuns before every test case.
AfterEachRuns 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 / DehydrateReconstruct / clean up local resources in shared fixtures after state transfer.

Prefixes

PrefixEffectUse case
F_Focus — only focused items runTight iteration during development
X_Exclude — always skippedTemporarily 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.

Suite Lifecycle

Predictable execution order with guaranteed cleanup — even on panics and fatal errors.

Execution order

BeforeAll ├── BeforeEach Test A AfterEach (deferred) ├── BeforeEach Test B AfterEach (deferred) ├── BeforeEach Test C AfterEach (deferred) AfterAll (via t.Cleanup — always runs)

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().

Hook signatures

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

Per-test context

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
}

Parallel execution

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.

Test DSL

Methods on *gotest.T for structuring tests. Every method maps to t.Run — the DSL adds semantics, not runtime machinery.

When / It

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)
        })
    })
}

Data-driven tests

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))
}
Each entry becomes a subtest. Uses the Desc or Name field for the test name, falls back to #0, #1, etc.

Snapshot testing

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.

Async polling

Package-level functions for polling conditions. Callbacks receive *gotest.R — an assertion recorder that captures failures without propagating them:

Eventually — poll until assertions pass
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)
})
Consistently — assert condition holds for full duration
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.

Helpers

MethodDescription
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.

Fixtures

Expensive setup — databases, containers, external services — defined once, shared across suites.

Package fixtures

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:

Fixture.BeforeEach └── Suite.BeforeEach Test Suite.AfterEach Fixture.AfterEach

Nesting

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:

DBFixture.BeforeAll ───┐ ├── APIFixture.BeforeAll CacheFixture.BeforeAll └── Suite.BeforeAll ├── Suite.BeforeEach Test Suite.AfterEach └── Suite.AfterAll ┌── APIFixture.AfterAll DBFixture.AfterAll ────┘ CacheFixture.AfterAll ─┘

Shared fixtures

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.
Fields assigned in Hydrate are automatically classified as local and excluded from serialization. Everything else transfers. No annotations needed.

SharedFixture dependencies

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)
}
Within the setup subprocess, dependent fixtures receive in-memory pointers — not serialized state. 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.

Assertions

Type-safe generics with compile-time checking. Zero external dependencies. Works with both *gotest.T and *testing.T.

Assertion failures automatically trace back to the test call site. Helper functions don't need t.Helper() — the framework resolves the outermost caller from the test file.

Equality & identity

FunctionDescription
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.

Errors

FunctionDescription
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.

Collections

FunctionDescription
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.

Comparison & numeric

FunctionDescription
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.

Strings, time & other

FunctionDescription
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.

Async

FunctionDescription
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.
All assertions work inside polling callbacks — use poll (a *gotest.R) exactly as you would use t. Failures are collected, not propagated, enabling retry semantics.
All assertions call t.Helper(), so failures report the caller's file and line — not the assertion library internals.

Configuration

Sensible defaults. Override only what you need — via project file, marker methods, or CLI flags.

Precedence

There are two independent configuration scopes:

Project scope — CLI flags override the project file. These control the test runner itself:

1. CLI flags --setup-timeout=2m, --min=80, --debounce=500ms 2. Project file .gotest.yml — checked into the repo

Code scope — marker methods override built-in presets. These control individual suites and fixtures:

1. In-code config SuiteConfig(), FixtureConfig(), SharedFixtureConfig() 2. Built-in defaults DefaultSuiteConfig(), DefaultFixtureConfig()

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.

Project file — .gotest.yml

Place 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.

FieldTypeDefaultCLI overrideEffect
tagsstringnone-tagsBuild tags, comma-separated (e.g. "integration,e2e").
setup-timeoutdurationnone--setup-timeoutTotal budget for all shared fixture setup. Omit to let each fixture's own config govern. Negative disables.
min-coverageint0 (off)--minMinimum coverage percentage, 0–100.
parallelint0 (auto)--parallelTotal concurrent test method budget. 0 means 2×GOMAXPROCS.
debounceduration200ms--debounceWatch mode re-run delay.
lint.skip[string]noneLint rules to disable: stdlib-test, testify.
.gotest.yml
tags: integration
setup-timeout: 2m
min-coverage: 80
parallel: 12
debounce: 500ms
lint:
  skip:
    - testify

Suite configuration

FieldDefaultEffect
Timeout30sPer-test-case deadline.
SetupTimeout30sBeforeAll / AfterAll deadline.
Retries0Per-test-case retry attempts on failure.
FailFastfalseStop the suite on first failure.
ParallelfalseRun test methods concurrently.

Fixture configuration

FieldDefaultEffect
Timeout2 minBeforeAll / AfterAll deadline.
Retries0BeforeAll retry attempts.
RetryDelay0Pause between retries.

Presets

Start with a preset, override individual fields:

PresetTimeoutSetupTimeoutRetriesUse case
DefaultSuiteConfig()30s30s0Unit and integration tests
IntegrationSuiteConfig()2 min5 min0Heavier integration tests
DefaultFixtureConfig()2 min0Standard fixtures
ContainerFixtureConfig()5 min1Testcontainers, image pulls
Use a negative duration (Timeout: -1) to explicitly disable a timeout. Zero means "keep the default."

Coverage

Statement-weighted coverage from the Go coverage profile. One source of truth, no heuristics.

How it's calculated

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:

covered = statements in blocks executed at least once total = all instrumented statements percentage = covered / total

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.

Breadth indicator

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.

Cross-package coverage

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.

Coverage uses the profile's numStatements as the sole metric. No line counting, no token scanning, no filesystem heuristics. The profile is the source of truth.

CLI Reference

One command, multiple modes. All go test flags pass through unchanged.

Commands

CommandEffect
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.SvcGenerate 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.

Flags

FlagEffect
--ciCI mode: fail on focus prefixes, snapshot read-only. Auto-detected from CI env var; opt out with GOTEST_CI=0.
--specAppend spec summary after normal test output.
--update-snapshotsRegenerate all snapshot files.
--no-cacheDisable 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-colorStrip ANSI codes from output.
--debugPreserve 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).
--githubGitHub 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).
Standard go test flags work unchanged: -v, -race, -cover, -count, -run, -json, -short, -timeout.

CI Integration

Failure-focused summaries, inline PR annotations, and coverage reports — out of the box.

gotest summary

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:

All tests pass
147 tests passed (2.3s)
Coverage: 82.4%
Failures show assertion output only
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.

GitHub Action

The repository includes a composite action at mvrahden/go-test@v1 that wraps gotest summary --github:

.github/workflows/test.yml
- 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

Action inputs

InputDefaultDescription
packages./...Package patterns to test.
racefalseEnable the race detector.
coveragefalseEnable coverage profiling and reporting.
min-coveragenoneMinimum coverage percentage (0–100). Fails the step if below.
flagsnoneAdditional gotest flags (--double-dash style).
go-test-flagsnoneAdditional go test flags (-single-dash style).
versiongomodVersion to install, or gomod to resolve from go.mod.

Action outputs

OutputDescription
exit-codeTest process exit code.
coverageCoverage percentage (empty if coverage not enabled).

Version resolution

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.

To use the 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.

Manual CI setup

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