Help us shape the future of Go! Take the 2026 Go Developer Survey

testkit

package
v1.0.14 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 2, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

Documentation

Overview

Package testkit provides a small set of test helpers that every forge-generated bootstrap_testing.go reinvents: a discard logger, a real-postgres ORM context (bare, or with the project's embedded migrations applied), an httptest harness that wraps a Connect-mounted service, and a claims-bearing AuthedContext for handlers that read the current user.

Why not absorb the whole bootstrap_testing.go?

The per-service NewTest<Service>(t, opts...) factory is project-specific: it knows the service's Deps shape, its Register method, and the proto Connect client type. None of that compresses into a library helper — every project's test factory looks slightly different. testkit only holds the genuinely shared sub-helpers; the wiring shim stays codegen.

Usage in generated code

Forge's bootstrap_testing.go template calls into testkit from defaultTestConfig:

cfg := &testConfig{
    logger: testkit.DiscardLogger(),
    cfg:    config.Defaults(), // the config Load yields with nothing set
    db:     testkit.NewPostgresDB(t), // when AnyServiceHasDB
}

Projects with embedded migrations also get a migrated variant (app.NewMigratedTestDB → NewMigratedPostgresDB) for tests that need the real schema.

Real postgres, not SQLite

forge is postgres-pinned. The DB helpers boot a real ephemeral postgres (pkg/pgtest: embedded-postgres by default, or the FORGE_TEST_POSTGRES_URL server) and hand each test its own isolated database. This is the same engine production runs, so migrations apply verbatim and there is no SQLite-portability subset to honor. The first call in a process boots the shared server (downloading the pg binary on a fresh machine); subsequent calls are cheap per-test databases.

And NewTest<Svc>Server delegates to testkit.NewTestServer(t, register), mounting the service bare:

srv := testkit.NewTestServer(t, func(mux *http.ServeMux) {
    svc.Register(mux)
})

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ApplyOptions

func ApplyOptions[C any, O ~func(*C)](cfg *C, opts ...O) *C

ApplyOptions applies each functional option to cfg and returns it.

Every generated per-component test factory opens with the same three lines — build the default config, range over the variadic options, call each — and it does so once per factory (NewTest<Svc>, NewTest<Svc>Server, and any the user adds). This is that loop, and it is the one piece of the option machinery that is expressible here: it never names the option's config type, only applies functions to it.

The options THEMSELVES cannot live here, and the reason is worth stating because it is the boundary of what this package can absorb. A per-component option is a `func(*testConfig)` where testConfig holds the project's own *config.Config and that component's own Deps — project types, unknown to forge/pkg. Expressing them generically (Option[C, D]) compiles, but Go infers type parameters only from ARGUMENTS, never from the return type, so every call site would have to spell them out:

NewTestOrder(t, testkit.WithLogger[config.Config, Deps](lg))   // generic
NewTestOrder(t, WithLogger(lg))                                // today

That is a worse test-writing experience in every project, permanently, to delete three-line functions from a generated file. So the options stay generated per component and only their application loop is shared.

O is a second type parameter rather than a plain `...func(*C)` because the per-component option is a NAMED type (`type TestOption func(*testConfig)`), and Go does not assign []TestOption to []func(*testConfig) — a named type and its underlying type are not identical in that position. Constraining O by `~func(*C)` accepts the named type and infers both parameters from the arguments.

Call it by FORWARDING a slice — `ApplyOptions(cfg, opts...)` — which is what a variadic factory already has. Passing no variadic argument at all leaves O with nothing to infer from and does not compile; forward an empty (even nil) slice for the no-options case.

func AuthedContext

func AuthedContext(t *testing.T, withClaims func(context.Context, *auth.Claims) context.Context, opts ...ClaimsOption) context.Context

AuthedContext returns a context carrying authenticated test claims, so handlers that read the current user (middleware.GetUser / middleware.ClaimsFromContext) see a real principal instead of failing CodeUnauthenticated before reaching business logic.

The claims context key is project-local — it lives in the generated pkg/middleware package, deliberately unexported so nothing bypasses the middleware. withClaims is therefore the project's own setter, the SAME function the production auth interceptor uses to install claims:

ctx := testkit.AuthedContext(t, middleware.ContextWithClaims)

Generated projects re-export this with the setter pre-bound as app.AuthedContext(t, opts...) — prefer that form in project tests.

Default claims: UserID "test-user", Email "test-user@example.test", Role/Roles "admin" (a convenient default for handlers that check roles). Override via ClaimsOption values.

func DiscardLogger

func DiscardLogger() *slog.Logger

DiscardLogger returns a slog.Logger that drops every record. Use it as the default logger in unit tests where log output would be noise. The logger is safe for concurrent use and never returns errors.

Tests that need to assert on log lines should construct their own logger backed by a *bytes.Buffer or testing.TB.Log; this helper exists for the common "I do not care what gets logged" case.

func NewConnectClient

func NewConnectClient[C any](
	t *testing.T,
	register func(mux *http.ServeMux, opts ...connect.HandlerOption),
	newClient func(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) C,
	handlerOpts ...connect.HandlerOption,
) (*httptest.Server, C)

NewConnectClient starts a test server with register mounted on its mux and builds a typed Connect client against it via newClient.

It folds together the two halves every generated NewTest<Svc>Server does by hand: NewTestServer (mux, httptest.Server, t.Cleanup) and the client construction, which needs the proto-specific `<svc>v1connect.New<Svc>Client` constructor. That constructor is generated code this package cannot name, so it arrives as a parameter — and because it is a plain argument, its type parameter INFERS at the call site, unlike the option types above:

srv, client := testkit.NewConnectClient(t,
    svc.Register, orderv1connect.NewOrderServiceClient)

The server is returned alongside the client for tests that need its URL or want to close it early; it is already registered for cleanup. register matches the generated service's own Register method, which takes variadic connect.HandlerOption alongside the mux; handlerOpts are forwarded to it (e.g. ServerPrincipal), followed by svcerr.Interceptor, innermost.

That last one is what production has: forge's chain builders end every handler chain with svcerr.Interceptor, so a handler returning a bare `svcerr.InvalidArgument("…")` answers invalid_argument. A test server without it answered unknown for the same handler — the documented handler shape failed only under test, and an unrecognised error published its raw text here while production redacts it. Passing svcerr.Interceptor yourself stays harmless: Wrap is idempotent.

func NewMigratedPostgresDB

func NewMigratedPostgresDB(t testing.TB, migrations fs.FS) orm.Context

NewMigratedPostgresDB returns a real-postgres ORM client with the project's embedded migrations applied, so handler preconditions (tables, indexes) hold in unit tests exactly as they do after AutoMigrate in production. migrations is typically the project's embedded `forgedb.MigrationsFS` (db/embed.go) — the same FS pkg/app/migrate.go feeds to golang-migrate. Generated bootstrap testing code exposes this as the app.NewMigratedTestDB(t) helper whenever the project has migrations.

Files are discovered under a "migrations/" directory inside the FS when present (matching the embed layout `//go:embed migrations/*.sql`), falling back to the FS root. Only `*.up.sql` files run, ordered by their numeric version prefix (the `NNNN_name.up.sql` golang-migrate convention), falling back to lexicographic order for non-numeric names.

The SQL executes against real postgres verbatim — the same engine production runs — so there is no portability subset to honor. A migration that postgres rejects fails loudly here (t.Fatalf names the file).

func NewPostgresDB

func NewPostgresDB(t testing.TB) orm.Context

NewPostgresDB returns a fresh, isolated real-postgres ORM client suitable for hermetic unit tests. The database is empty — callers that need a schema should run migrations (NewMigratedPostgresDB) or create tables explicitly. The connection and the underlying database are cleaned up via t.Cleanup, so the caller does not need to defer a close.

Each call yields its OWN database on the process-shared ephemeral postgres (pkg/pgtest), so two NewPostgresDB calls in the same test are fully isolated — the right default for table-driven tests that mutate rows. The first call in a process boots the shared server (downloading the postgres binary on a fresh machine, or connecting to FORGE_TEST_POSTGRES_URL); subsequent calls only CREATE DATABASE.

func NewTestServer

func NewTestServer(t *testing.T, register func(mux *http.ServeMux)) *httptest.Server

NewTestServer starts an httptest.Server backed by a fresh http.ServeMux and invokes register so callers can mount one or more Connect services on the mux. The server is closed via t.Cleanup, so the caller does not need to defer srv.Close.

The split — register receives the mux instead of the server — keeps testkit independent of any specific service type. A typical call site looks like:

srv := testkit.NewTestServer(t, func(mux *http.ServeMux) {
    svc.Register(mux, connect.WithInterceptors(/*...*/))
})
client := myservicev1connect.NewMyServiceClient(http.DefaultClient, srv.URL)

The client construction stays in the per-service test factory because it requires the proto-specific connect package and client constructor.

func Placeholder

func Placeholder[T any](field *T, name string)

Placeholder fills *field, when it is nil, with a stand-in that was never initialised. The generated harness's WithPlaceholderDeps option calls it for every required Deps field the harness cannot construct — a concrete handle such as `SQL *sql.DB`, or a func with no framework default — so a test whose rows never reach that dependency can still construct the service past validateDeps.

What it stores depends on the field's kind:

  • a pointer gets new(T): the zero value, which for an infrastructure handle like *sql.DB panics on first use rather than doing anything. For a type that works at its zero value it is NOT inert: a generated config message (*configv1.FooConfig) becomes a valid, empty config — every getter answers its zero value — and the service runs on it (reliant's model endpoints read the empty control-plane URL as self-hosted, the permissive network policy). No config value is distinguishable from configuration, so a test whose behaviour turns on config supplies the config it means;
  • a map or slice gets an empty one, a channel an unbuffered one;
  • a func gets one that panics naming the field when it is called.

It is a placeholder, not a double: nothing about it is faithful to the real dependency, and a row that reaches it has a real dependency to supply. A non-nil field is left alone, so a value the test set through With<Service>Deps always wins, as does any kind Placeholder cannot make (an interface).

func ServerPrincipal

func ServerPrincipal(withClaims func(context.Context, *auth.Claims) context.Context, opts ...ClaimsOption) connect.HandlerOption

ServerPrincipal returns a handler option that installs a test principal in the HANDLER's context on every call — unary and streaming alike — through the project's own claims setter (middleware.ContextWithClaims), the same function the production auth interceptor calls.

This is the over-the-wire twin of AuthedContext, and it exists because AuthedContext cannot do this job: it puts claims on a context the TEST holds, and a context does not cross HTTP. A test that drives a service through its typed Connect client therefore needs the principal installed where the handler runs, or every auth-gated RPC answers Unauthenticated before reaching the logic under test.

It stands in for the production authentication step only — whatever the handler does with the principal (its own role checks, org scoping) still runs, so a test exercises denials by passing claims that lack the role:

testkit.ServerPrincipal(middleware.ContextWithClaims, testkit.WithRoles("viewer"))

Defaults are AuthedContext's (UserID "test-user", Role/Roles "admin"), so an in-process row and an over-the-wire row agree on who is calling.

func Slow added in v1.0.0

func Slow(t testing.TB, why string, opts ...SlowOption)

Slow marks t as a slow test: it skips under -short (the fast tier) and in the integration lane, and runs in the full tier (`go test` without -short, `task test` in a forge project). why names the work that makes it slow — "renders prod through `forge env render`", "builds the forge binary" — and is the skip message, so a reader of a -short run sees what was left out and where it runs instead.

Call it first, before the slow work. A helper that does the slow work for several tests may call it instead, with the test's t.

func SlowSkipped added in v1.0.0

func SlowSkipped() bool

SlowSkipped reports whether Slow skips every test of this binary: under -short and in the integration lane. It is for work a package does before any test starts — a TestMain that builds a binary only slow tests use.

func StubNotConfigured

func StubNotConfigured(stubType, method string) error

StubNotConfigured is what a forge-synthesized test stub returns, as its error result, from a method the test never programmed.

The stubs

The generated test harness fills every required interface-typed Deps field so NewTest<Service>(t) constructs without the test wiring each collaborator by hand. Where forge already generates a Mock<Role> for the field's interface (a mock_gen.go), the harness installs that mock and this error is not involved: an unset mock method returns contractkit.MockNotSet. For an interface forge mocks nowhere — a third-party or standard-library one, or a handler-local one outside a forge:deps deps.go — it synthesizes a Stub<Field>, a programmable double: a <Method>Func field per method, plus an embedded contractkit.Recorder. A test that drives a handler through the dependency sets the Func fields for the methods it reaches.

Why an unprogrammed method refuses instead of answering nil

Any method whose last result is `error` returns this error (with zero values beside it) until its Func is set. A nil error is not an empty answer, it is an affirmative claim that the call SUCCEEDED, and a stub that did nothing has not earned it:

  • From a lookup, (nil, nil) means "found" — Go's convention is that a nil error makes the value valid — so `chat, err := repo.GetChat(..)` followed by `chat.UserID` dereferences nil. Worse, a handler's not-found and failure branches became unreachable through the stub, because it could only ever say "found". Every reliant P3b lane hand-wrote a fake to get past it.
  • From a policy check, nil means PERMITTED. A dogfood run wrote a test asserting a caller without the required role could not obtain an upload URL; it failed, because the stub had authorized the call. The application's own authorization code was correct and the harness had switched it off, silently.

A method with no error result has no channel to refuse on and returns its zero values.

The same default forge already makes for mocks

contractkit.MockNotSet is this error's twin: a forge-generated MockService returns it when a method is invoked whose Func field the test never assigned. Both lead with the same "<Double>.<Method>Func not set" fingerprint, so an assertion on that text holds for either double.

The pair is deliberately NOT one function. contractkit is imported by generated production-adjacent mock code; testkit is the test-harness library. Sharing would couple them for a one-line format string.

What a test does about it

Program the method, or supply a real implementation, through the harness's With<Service>Deps option:

repo := &questions.StubRepo{
    GetChatFunc: func(context.Context, string) (*db.Chat, error) {
        return nil, db.ErrNotFound
    },
}
svc := questions.NewTestQuestions(t, questions.WithQuestionsDeps(questions.Deps{Repo: repo}))

The error names the stub type and the method, so a test that trips it is told precisely which collaborator to configure.

func UnfilledDepsHint

func UnfilledDepsHint(withDeps string, unfilled ...string) string

UnfilledDepsHint is the line a generated NewTest<Service> appends to a construction failure: which required dependencies the harness could not build and left nil, and the two ways to supply them. withDeps is the harness's With<Service>Deps option name; unfilled describes each field as "Deps.SQL (*sql.DB)". It returns "" when unfilled is empty, so the failure reads exactly as before when the cause is something else.

Without it the failure was validateDeps' own "Deps.SQL is required", which says what is missing and nothing about the harness option that supplies it.

Types

type ClaimsOption

type ClaimsOption func(*auth.Claims)

ClaimsOption mutates the default test claims built by AuthedContext.

func WithClaims

func WithClaims(claims auth.Claims) ClaimsOption

WithClaims replaces the default test claims wholesale. Later options still apply on top.

func WithEmail

func WithEmail(email string) ClaimsOption

WithEmail overrides the test claims' Email.

func WithOrgID

func WithOrgID(orgID string) ClaimsOption

WithOrgID overrides the test claims' OrgID.

func WithRoles

func WithRoles(roles ...string) ClaimsOption

WithRoles overrides the test claims' role set. The first role also becomes the singular Role field, matching how the auth validator populates both.

func WithUserID

func WithUserID(id string) ClaimsOption

WithUserID overrides the test claims' UserID.

type Fixture

type Fixture struct {
	Name        string                      `json:"name"`
	Description string                      `json:"description"`
	Tables      map[string][]map[string]any `json:"tables"`
}

Fixture is the on-disk shape of a forge fixture file: a named bundle of table rows. It matches the JSON the projects already keep under db/fixtures/*.json (the "Auto-generated seed data" files), so existing fixtures load without conversion:

{
  "name": "users",
  "tables": {
    "users": [ {"id": "…", "email": "…"}, … ]
  }
}

Each row is an object of column→value; values are decoded as JSON scalars (string/number/bool/null) and passed as bind parameters, so the database driver does the type coercion against the real column types.

func LoadFixture

func LoadFixture(t testing.TB, db orm.Context, path string) *Fixture

LoadFixture reads the fixture JSON at path and inserts its rows into db, failing the test on any error. db is expected to be a migrated real-postgres handle (NewMigratedPostgresDB) so the target tables exist; LoadFixture does not create schema, it only seeds data.

Rows insert in a stable column order (sorted) with parameterized values, one INSERT per row, in the order they appear in the file — so a fixture can be authored to respect foreign-key ordering within a table. Tables themselves insert in sorted name order for determinism; cross-table FK ordering should be handled by loading dependency fixtures first, or by keeping FK-related rows in one fixture file ordered correctly.

Returns the parsed Fixture so a test can assert on what it loaded (counts, ids) without re-reading the file.

db := app.NewMigratedTestDB(t)
fx := testkit.LoadFixture(t, db, "../../db/fixtures/users.json")
// …exercise a read path that expects len(fx.Tables["users"]) rows…

type ScenarioBuilder

type ScenarioBuilder[D any, S any] struct {
	// contains filtered or unexported fields
}

ScenarioBuilder assembles a service-under-test together with a typed override value in a few lines, so a test (or an LLM exploring the code) can stand up a real instance with one collaborator swapped for a mock without re-deriving the per-service factory boilerplate.

The type parameter D is the service's dependency value — almost always the generated `<svc>.Deps` struct that the project's pkg/app/testing.go factories already accept via With<Svc>Deps(deps). S is the assembled thing Build returns (a *Service, a Service interface, or an (server, client) pair via a struct).

Why a builder when the factories already exist

The generated NewTest<Svc>(t, opts...) factories are the composition roots: they fill the cross-cutting deps (logger/config), auto-stub required collaborators, and call <svc>.New. ScenarioBuilder does NOT duplicate that — it composes with it. You hand Build the factory (closed over the generated NewTest<Svc> + its With<Svc>Deps option) as the assemble func; the builder's only job is to accumulate typed overrides onto a zero-value D and apply them in order before assembly. The result is the "stand up a service with one mocked collaborator in ~3 lines" ergonomic the redesign note (§7g) calls for:

svc := testkit.NewScenario(func(t *testing.T, d user.Deps) user.Service {
    return app.NewTestSvcUser(t, app.WithSvcUserDeps(d))
}).
    With(func(d *user.Deps) { d.Audit = mockAudit }). // swap one collaborator
    Build(t)

Everything not overridden falls through to the generated factory's defaults (discard logger, auto-stubbed collaborators), so the mock above is the only thing the test states.

Composing with a real DB

Build receives *testing.T, so the assemble closure can also reach for NewMigratedPostgresDB / LoadFixture to give the service a real seeded repository. See the example tests in the downstream apps.

func NewScenario

func NewScenario[D any, S any](assemble func(t *testing.T, deps D) S) *ScenarioBuilder[D, S]

NewScenario starts a builder from the zero value of D. assemble is the adapter that turns an effective D into the assembled instance — typically a one-line closure over the generated NewTest<Svc> factory and its With<Svc>Deps option (see the type doc). assemble must not be nil; Build fails the test if it is.

func (*ScenarioBuilder[D, S]) Build

func (b *ScenarioBuilder[D, S]) Build(t *testing.T) S

Build applies every registered override to a zero-value D in order and hands the result to the assemble func, returning the assembled instance. It marks itself a test helper so failures point at the caller.

func (*ScenarioBuilder[D, S]) With

func (b *ScenarioBuilder[D, S]) With(mutate func(*D)) *ScenarioBuilder[D, S]

With registers a typed mutation against the dependency value. Mutations apply in registration order at Build time, so a later With can override an earlier one. This is the functional-options seam for injecting a mock collaborator, a real repository, or any other field on D:

b.With(func(d *billing.Deps) { d.Users = mockUsers })

Returns the builder for chaining.

func (*ScenarioBuilder[D, S]) WithDeps

func (b *ScenarioBuilder[D, S]) WithDeps(deps D) *ScenarioBuilder[D, S]

WithDeps replaces the accumulated dependency value wholesale with deps. Later With mutations still apply on top, so this is the "start from a fully-specified Deps, then tweak one field" entry point. Equivalent to a With that assigns *d = deps, but reads more clearly at the call site.

type SlowOption added in v1.0.0

type SlowOption func(*slowConfig)

SlowOption configures Slow.

func Inputs added in v1.0.0

func Inputs(globs ...string) SlowOption

Inputs declares the files a slow test depends on: globs relative to the module root, slash-separated, where `*` matches within one path segment and `**` matches any number of segments ("deploy/kcl/**", "db/migrations/*.sql").

The arguments must be string literals. They are read from source by forge's slowtest analyzer, without running the test; `forge lint --slow-tests` reports a non-literal argument. Slow validates them on every run — including -short runs, where it then skips — so a malformed glob fails in the fast tier rather than going unread.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL