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. Benchmarking — benchmark suites, baselines, deltas, and the gate
  10. Fuzzing — fuzz targets, struct inputs, sessions, crashers
  11. CLI Reference — commands, flags, go test passthrough
  12. Linter — rule tiers, suppression, exit codes
  13. 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"email is new"
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 new ✓ 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. Setup each suite of a package can bind; set up per suite.
*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, before the suite's fixtures are set up.
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}. Per-test state lives in the context a returning BeforeEach provides, never on the shared suite struct; the generator rejects a BeforeEach that returns nothing in a parallel suite, and a suite without per-test state needs none.

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 new", 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 ./....

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, wired into every suite that needs it.

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.

A package fixture lives as long as the suite that binds it: set up before the suite's BeforeAll, torn down after its AfterAll, also when a test panics or the run is stopped. Setup that must happen once per run belongs in a shared fixture. A suite sets up only the fixtures it binds and their dependencies, so a fixture it does not use never delays or fails it.

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 generation 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 *gotest.T, *testing.T, *gotest.B and *gotest.F.

Assertion failures automatically trace back to the test call site. Helper functions don't need t.Helper() — the failure is located at the line that failed, a helper's included, and the message names 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.
Nil(t, obj)Nil check for non-comparable nilables (slices, maps, funcs). Type-guarded; comparable nilables use Zero.
NotNil(t, obj)Inverse of Nil.
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.
Go(t, fn)Run fn on a goroutine that captures panics and re-raises them on the test's goroutine. Returns a wait func; also registered as cleanup.
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, the default outer budget of 2 minutes applies; set it to 0 to disable the outer budget and let only the per-fixture timeouts govern.

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-timeoutduration2m--setup-timeoutTotal budget for all shared fixture setup. 0 disables.
timeoutduration15m--timeoutGlobal pipeline deadline. 0 disables.
min-coverageint0 (off)--minMinimum coverage percentage, 0–100.
parallelint0 (auto)--parallelTotal concurrent test method budget. 0 means 2×GOMAXPROCS, auto-halved under -race/-msan/-asan.
compile-parallelint0 (auto)--compile-parallelConcurrent compilation processes. 0 means NumCPU, auto-halved under -race/-msan/-asan.
debounceduration200ms--debounceWatch mode re-run delay.
lint.skip[string]none—Non-integrity lint rules to disable: assertion-simplify, assertion-redundant, fail-guard, t-escape, 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.
FailFastfalseStop the suite on first failure.
ParallelfalseRun test methods concurrently.
ExclusivefalseSchedule the suite's process strictly alone — after every non-exclusive suite has finished, one exclusive suite at a time. For wall-clock-sensitive or resource-contended suites.

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 min—0Standard fixtures
ContainerFixtureConfig()5 min—1Testcontainers, image pulls
A duration the returned config leaves at zero (omitted or explicit) gets the default, as if the marker were absent; gotest.NoDeadline disables a deadline. Booleans, Retries and RetryDelay are used as written, so gotest.SuiteConfig{Parallel: true} runs in parallel with the 30s defaults.

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.

Merged and cross-package profiles

Merged profiles — concatenated per-suite profiles, or overlapping -coverpkg runs — can repeat the same block. Each unique file and range is counted once, keeping the maximum execution count, matching go tool cover behavior. -coverpkg itself is a standard go test flag and passes through unchanged.

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.

Benchmarking

Benchmarks are suite methods. A baseline is a file. A gate is a number.

Writing benchmarks

A Benchmark-prefixed method on any suite takes a *gotest.B. The suite's fixtures and lifecycle hooks apply as they do to tests, so setup happens where it always does — outside the measured loop.

func (s *CacheTestSuite) BenchmarkGetHit(b *gotest.B) {
    key := s.Corpus.Keys[0]        // read fixture state above the loop
    for b.Loop() {
        s.cache.Get(key)
    }
}
Reading fixture-backed state inside b.Loop() times the fixture, not the code under test. The bench-fixture-io lint rule flags it.

Running them

CommandEffect
gotest bench ./...Run every suite's generated Benchmark<SuiteName> wrapper, one suite at a time.
gotest bench --spec ./...Render the results as a spec tree instead of raw benchmark lines.
-bench='^BenchmarkCacheTestSuite$/^BenchmarkGetHit$'Scope to one method: the wrapper runs each method under b.Run with its own name.
-benchtime=100x, -count=3Standard go test flags, forwarded unchanged.
Benchmark suites dispatch serially, one at a time: concurrent benchmarks measure each other. -test.benchmem is on by default, so every line carries B/op and allocs/op.

Baselines, deltas, and the gate

FlagEffect
--save=<path>Write this run's results as a JSON baseline. Bare --save= uses bench.baseline from .gotest.yml.
--against=<path>Compare this run to a saved baseline and print a delta table. Defaults to bench.baseline.
--gate=<pct>Exit 1 when the worst significant regression exceeds pct percent. Needs a comparison; defaults to bench.gate, where 0 disables.
--jsonOne versioned JSON document: the run's results, every delta when a comparison ran, and the gate verdict when a gate is active. For tooling, including the editor.

In .gotest.yml, bench.baseline and bench.gate set the defaults, so CI and a laptop agree on what a regression is without repeating flags.

A baseline records the goos, goarch and Go version that produced it. Compare against one recorded elsewhere and --against says so on stderr — WARN: baseline was recorded in a different environment, naming each field — while --json carries the same under envMismatch. The comparison still runs and the exit code is unchanged: across machine classes the deltas measure the machines too, and only you know whether two runners are alike enough for that to matter.

Fuzzing

Fuzz targets are suite methods, and their inputs are Go types — including structs the engine cannot take.

Writing a fuzz target

A Fuzz-prefixed method takes a *gotest.F. Seeds go in with f.Add, the property goes in f.Fuzz, and the generator emits one Fuzz<SuiteName>_<MethodName> wrapper per method.

func (s *FrameCodecTestSuite) FuzzFrameRoundTrip(f *gotest.F) {
    f.Add(Frame{Version: 1, Topic: "orders.created", Payload: []byte(`{"id":1}`)})
    f.Fuzz(func(t *gotest.T, in Frame) {
        out, err := Decode(Encode(in))
        gotest.NoError(t, err)
        gotest.Equal(t, in, out)   // property: round trip
    })
}

Struct inputs

Go's fuzzing engine accepts fifteen types, none of them a struct. gotest fans a struct into one engine argument per leaf field at generation time and reassembles the value before each execution. Two consequences follow: a type the fan cannot express is refused when you generate, not hours into a session, and a seed stays an ordinary Go literal instead of a byte blob.

Sessions

FlagEffect
--for=<dur>Wall-clock budget for the whole session (default 1m), split across targets — never less than 10s each. --for=0 fuzzes until interrupted.
--jobs=<n>Concurrent targets (default max(1, GOMAXPROCS/2)). Each target is its own go test -fuzz process.
--target=<Fuzz...>Fuzz one target by wrapper name; an unmatched name lists what exists.
--no-harvestSkip mining table-test literals into seeds for this run (fuzz.harvest in .gotest.yml).

Exit codes: 0 when the budget ran out with nothing found, 1 for a finding — a failing target or a new crasher file — and 2 when the session could not run as asked.

Seeds replay without this command: every f.Add seed and every corpus entry under testdata/fuzz/ runs as an ordinary subtest of a plain gotest run. gotest fuzz is how you spend time looking for new inputs.

Crashers

CommandEffect
gotest fuzz triage ./...Re-run every corpus entry, printing its decoded input and the failure cause, or status: no longer failing. Exits 1 while any crasher still fails.
gotest fuzz promote ./...Splice each crasher into its method as a permanent f.Add(...) seed, after the last existing seed, and delete the corpus file.

A crasher whose method cannot be located with confidence is left in place with a warning; promote never partially edits source.

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. With --static, render it from source without running.
gotest discover ./...Discover test suites and output JSON (feeds editor integrations).
gotest summary ./...Failure-focused summary for CI. Shows only failing tests with assertion output.
gotest bench ./...Run Benchmark* suite methods serially; --save, --against and --gate keep and compare baselines.
gotest fuzz ./...Search for new failing inputs with Fuzz* suite methods for a time budget (--for); fuzz triage and fuzz promote handle what it finds.
gotest lint ./...Static analysis for test suites — see Linter.
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 orphaned generated files.
gotest refactor toggle-focus <file> <id>Toggle F_/X_ prefixes programmatically.
gotest skill installInstall the coding-agent skill shipped with this version (v1.34+); --dir picks the skills directory.
gotest prepare ./...Start shared fixtures for debugging; blocks until SIGTERM.
gotest versionPrint version information.
gotest help [subcommand]General or subcommand-specific help.

Flags

FlagEffect
--ciCI mode: fail on focus prefixes, snapshot read-only. Auto-detected from CI env var; opt out with GOTEST_CI=0.
--specRender the spec view in place of the default 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 (default: 2m, 0 to disable).
--timeout=<dur>Global pipeline deadline (default: 15m, 0 to disable).
--parallel=<n>Total concurrent test method budget (default: 2×GOMAXPROCS, auto-halved for -race/-msan/-asan).
--compile-parallel=<n>Concurrent compilation processes (default: NumCPU, auto-halved for -race/-msan/-asan).
--save=<path>Save a benchmark run as a JSON baseline (bench).
--against=<path>Compare a benchmark run against a saved baseline and print the delta table (bench).
--gate=<pct>Fail if the worst significant benchmark regression exceeds the threshold (bench).
--jsonEmit one versioned JSON report instead of human output (bench).
--for=<dur>Approximate wall-clock budget for the whole fuzz session, split across targets (fuzz; default 1m, 0 removes the budget).
--jobs=<n>Maximum concurrent fuzz targets (fuzz; default max(1, GOMAXPROCS/2)).
--target=<name>Fuzz exactly one generated target by its wrapper name (fuzz).
--no-harvestDo not mine table-test literals into fuzz seeds for this run.
--staticRead the specification from source without running the suites; nodes carry no verdict (spec command).
--render-onlyWith --input, exit 0 on a failing or incomplete stream: report whether rendering succeeded, not the test verdict.
--debounce=<dur>Watch mode debounce interval (default 200ms).
--input=<path>Read test output from file instead of running tests (spec, summary). Exits 2 when the input holds no test events, a package or failed build never reported its verdict, or a package failed to build.
--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).
--badge=<file>Write a self-contained coverage badge SVG; enables -coverprofile when nothing else does (summary command).
Standard go test flags work unchanged: -v, -race, -cover, -count, -run, -skip, -json, -short, -timeout. Every matched package ends in a verdict, as with go test, printed in package order: one gotest runs nothing in reports [no test files], [no suites] (stdlib tests only, standard library packages too) or 0.000s [no tests to run] (benchmarks only, every suite excluded with X_, or -run deselected every suite), in text and in -json, where each package opens with one start event as in go test -json. Patterns that match no package at all verified nothing: every run mode exits 2 with FAIL: "<pattern>" matched no packages on stderr. A pattern that matches nothing beside others that did only warns, as with go test. generate and discover, which verify nothing, only warn and exit 0. An empty pattern ("") is the current directory, as with go test. gotest takes packages, not files: a .go file argument exits 2 and names it.

Linter

Twenty-eight rules in three tiers. A rule's tier determines how it may be suppressed.

Integrity rules

Violations can make test outcomes unreliable or leak resources. These rules can only be suppressed per line — never project-wide.

RuleDetects
focusCommitted F_ prefixes on suites or methods.
receiverSuite methods with value receivers instead of pointer receivers.
lifecycle-typoMethod names one typo away from a lifecycle hook (BeforAll, AfterEeach).
lifecycle-pairBeforeAll without AfterAll — resources may leak.
x-lifecycleX_ prefix on a lifecycle hook — a no-op.
test-signatureTest methods not accepting *gotest.T (or *testing.T).
suite-lifecycleCleanup/Parallel/Run via t.T() — they bypass the suite lifecycle. Run is flagged in any function, since its subtests drop the suite's deadline.
poll-scopeAssertions inside Eventually/Consistently callbacks using the outer t instead of poll.
assertion-type-guardNil/Empty on types their runtime guards would reject.
shared-fixture-undeclaredSuite reads a shared fixture it never declared — window scheduling only starts declared fixtures, so the read may hit one that never started or is already released.
generated-fileGenerated overlay files present in source control.
bench-loopBenchmark* methods that never touch b.Loop()/b.N — nothing iterates, so the numbers lie.
fuzz-determinismFuzz targets reading time.Now, math/rand or os.Getenv — corpus replay and coverage guidance degrade.
fuzz-struct-corpusOn-disk corpus entries for a struct-typed fuzz target — bound to the field order; gotest fuzz promote turns them into typed seeds.

Expressiveness rules

The test is correct but its syntax can be improved. These rules can be suppressed per line or project-wide via lint.skip; -fix applies the safe rewrites in one run, overlapping ones included, then reports what it could not fix (exit 3 while findings remain); -diff prints exactly what -fix would write, as a unified diff with paths relative to the current directory, and writes nothing — gotest lint -diff ./... | git apply applies it (a file outside that directory needs git apply --unsafe-paths; a symlink is named by the file it leads to, which -fix writes).

RuleDetects
assertion-simplifySimplifiable assertions: True(t, a == b) → Equal, Len(t, x, 0) → Empty, …
assertion-redundantAn assertion made redundant by the next one on the same argument.
fail-guardif cond { Fail/Fatal(...) } guards — the assertion expresses the check directly.
t-escapeUnnecessary t.T() convenience escapes (Errorf, Helper, Log, Fatal, …), and t.T().Context()/t.T().Deadline(), which drop the suite's Timeout — also inside *testing.T helpers handed t.T().
config-no-deadlineA literal negative duration on SuiteConfig.Timeout/SetupTimeout or FixtureConfig.Timeout — every negative disables the deadline; the fix spells it gotest.NoDeadline.
behavior-wordingWhen("when …") or It("it …") — the spec renders the connective and the ✓ glyph plays "it"; the fix drops the word.
bench-fixture-ioFixture-backed reads inside the measured loop — times the fixture, not the code.
bench-waittime.Sleep/Eventually/Consistently inside the measured loop — times the wait.
fuzz-no-oracleFuzz callbacks that assert nothing — only panics are caught.
fuzz-seedFuzz targets with no f.Add seed — exploration starts blind.
fuzz-hook-ioIO in BeforeEach/AfterEach of a fuzzing suite — the hooks replay around every execution.
fuzz-raw-seedRaw []byte seeds on a position that does not take []byte — write a typed literal.

Migration rules

Adoption aids for codebases moving to gotest — coexistence is legitimate. These rules can be suppressed per line or project-wide via lint.skip.

RuleDetects
stdlib-testfunc TestX(*testing.T) — consider a suite method; coexisting stdlib tests are legitimate.
testifyAny github.com/stretchr/testify/* import — migration incomplete.

Suppression

Silence a single diagnostic with //nolint:<rule> on its line or in a comment block of its own ending on the line directly above it; on the package line it applies file-wide. Non-integrity rules can also be disabled per run (-skip-<rule>) or project-wide (.gotest.yml → lint.skip).

Exit codes

CodeMeaning
0No findings (with -fix or -diff: none left after fixing).
1Nothing analyzable: one or more target packages do not compile — they could not be analyzed, so the run fails instead of passing silently; patterns that match no package, or name a directory or package that does not exist (a pattern matching nothing beside others that do only warns on stderr, as go does); fixed sources that no longer compile; or fixed files that could not be written (-fix writes them all or none; on a failure stderr names any that were).
2Usage or configuration error (e.g. an integrity rule in lint.skip).
3Findings reported (with -fix or -diff: the findings left after fixing).
130-fix or -diff stopped by a signal; nothing was written (once the fixed files are being written, they all are first).
The linter is a standard go/analysis analyzer, built into the CLI as gotest lint. There is no golangci-lint plugin — run it as its own CI step alongside your existing linter. In GitHub Actions, gotest lint auto-detects the runner (GITHUB_ACTIONS=true, or explicitly via --github) and additionally emits inline PR annotations per finding plus a findings table in the step summary; exit codes and suppression are unchanged.

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@v7
- uses: actions/setup-go@v7
  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).
badgefalseRender a coverage badge and publish it as coverage.svg to badge-branch, from the default branch only. Turns coverage on; needs contents: write.
badge-branchci/badgesBranch the badge is published to; created automatically on first use.
benchfalseRun benchmarks after the tests (gotest bench --spec --json).
bench-baselinenoneBaseline JSON file to compare benchmarks against (--against).
bench-gatenoneFail the run if a benchmark regresses by more than this percent (--gate).
bench-savefalseSave the benchmark run as a JSON baseline at this path (--save); an empty string saves to bench.baseline from .gotest.yml.
fuzzfalseRun a budgeted fuzz session after the tests (gotest fuzz --for); a new crasher fails the step.
fuzz-fornoneApproximate wall-clock budget for the fuzz session; empty uses the CLI's default of one minute.
fuzz-cachetrueCarry Go's fuzz cache between runs, so each session resumes where the last one stopped.
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).
badgePath of the rendered coverage badge SVG (empty unless badge is enabled and the tests ran).
bench-reportPath to the gotest bench --json report file (empty if bench is not enabled).
bench-breached-keysComma-joined benchmark keys that breached the gate (empty if none or no gate).
fuzz-crashersComma-joined workspace-relative paths of the corpus files the fuzz session added (empty if none or fuzz is not enabled).

Version resolution

By default (version: gomod), the action runs the gotest your project's go.mod selects: go tool when the tool is declared there, go run otherwise. 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: declare it as a tool with go get -tool github.com/mvrahden/go-test/cmd/gotest@<version>, naming a release whose go line your module takes (a bare or @latest go get can raise your module's go line).

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