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.
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)) }
Callback API — same semantics, different style:
t.Each(cases, func(it *gotest.T, tc Case) {
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:
t.MatchSnapshot(render(input)) // auto-named from test path t.MatchSnapshot(render(other), "variant") // explicit snapshot name
On first run, the snapshot is created. On subsequent runs, the output is compared and failures include a diff. Update all snapshots with gotest --update-snapshots ./....
Two forms — a simple boolean check, or rich assertion polling with the full assertion library:
gotest.Eventually(t, func() bool { return s.svc.IsReady() }, 5*time.Second, 100*time.Millisecond)
t.Eventually(5*time.Second, 100*time.Millisecond, func(poll *gotest.T) { result, err := s.store.Get("key") gotest.NoError(poll, err) gotest.Equal(poll, "completed", result.Status) })
The method form collects intermediate failures without propagating them. On timeout, the last poll's assertion failures are reported. Consistently works the same way — it asserts a condition holds for the full duration and fails on the first violation.
| 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 child fixture points to its parent, and the generator wires the lifecycle automatically:
type InfraFixture struct { Pool *pgxpool.Pool } func (f *InfraFixture) BeforeAll(ctx context.Context) error { // start database container, populate f.Pool } type APIFixture struct { Infra *InfraFixture // named field — wired automatically ServerURL string } func (f *APIFixture) BeforeAll(ctx context.Context) error { // start API server using f.Infra.Pool } type OrderTestSuite struct { Fixture *APIFixture // accesses f.Infra.Pool and f.ServerURL }
The parent's hooks run first, wrapping the child's. The suite sees the fully initialized fixture chain:
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) Hydrate(ctx context.Context) error { var err error f.Pool, err = pgxpool.New(ctx, f.ConnStr) return err }
Hydrate are automatically classified as local and excluded from serialization. Everything else transfers. No annotations needed.
Type-safe generics with compile-time checking. Zero external dependencies. Works with both *gotest.T and *testing.T.
| 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, fn, timeout, tick) | Poll func() bool until true or timeout. |
Consistently(t, fn, duration, tick) | Assert func() bool holds for the full duration. |
func(poll *T), use the method forms on *T.
t.Helper(), so failures report the caller's file and line — not the assertion library internals.
Sensible defaults. Override only what you need via marker methods.
| 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, test, cleanup. 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 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 generated files. |
gotest refactor toggle-focus <file> <id> | Toggle F_/X_ prefixes programmatically. |
| Flag | Effect |
|---|---|
--ci | Fail the build if any focus prefix is committed. |
--spec | Append spec summary after normal test output. |
--update-snapshots | Regenerate all snapshot files. |
--min=<pct> | Fail if coverage falls below threshold. |
--format=md | Markdown output for spec command. |
--output=<path> | Write formatted output to file. |
--no-color | Strip ANSI codes from output. |
--debug | Keep generated files after the run. |
--setup-timeout=<dur> | Shared fixture setup deadline (default 1m). |
--debounce=<dur> | Watch mode debounce interval (default 200ms). |
--input=<path> | Read test output from file instead of running tests (spec command). |
go test flags work unchanged: -v, -race, -cover, -count, -run, -json, -short, -timeout.