testkit

package
v0.0.3 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 18 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, a permissive Authorizer for non-authz tests, a claims-bearing AuthedContext for handlers that read the current user, and a tenant-context helper for multi-tenant tests.

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.Config{},
    authz:  testkit.PermissiveAuthorizer{},
    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 SAME interceptor chain shape production uses — only the authorizer policy differs (permissive by default):

srv := testkit.NewTestServer(t, func(mux *http.ServeMux) {
    svc.Register(mux, connect.WithInterceptors(
        middleware.AuthzInterceptor(deps.Authorizer),
    ))
})

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

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" (permissive against generated RBAC tables, mirroring the permissive default Authorizer). 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 NewMigratedPostgresDB

func NewMigratedPostgresDB(t *testing.T, 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.T) 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 WithTestTenant

func WithTestTenant(ctx context.Context, tenantID string) context.Context

WithTestTenant returns a context with the given tenant ID set, using the same context key that pkg/tenant's interceptor uses in production.

Use in multi-tenant unit tests to simulate an authenticated tenant context without going through the full auth + tenant interceptor chain:

ctx := testkit.WithTestTenant(context.Background(), "tenant-123")
resp, err := svc.CreateThing(ctx, ...)

Generated projects re-export this as middleware.WithTestTenant / app.WithTestTenant when MultiTenantEnabled is true; calling either resolves to this helper.

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.T, 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 PermissiveAuthorizer

type PermissiveAuthorizer struct{}

PermissiveAuthorizer is an Authorizer implementation for use in unit tests. It allows every call. Production authorizers deny by default (fail-closed), but tests typically want to exercise business logic without authz noise — so the generated NewTest<Service> wires this in as the default authz.

Tests that need to exercise real authz rules should pass their own Authorizer via WithAuthorizer(...) or supply a full Deps via With<Service>Deps(...).

PermissiveAuthorizer satisfies any Authorizer interface with the canonical forge shape:

CanAccess(ctx context.Context, procedure string) error
Can(ctx context.Context, claims *auth.Claims, action, resource string) error

In generated projects, the project-local middleware.Authorizer interface uses *middleware.Claims, which is itself a type alias for *auth.Claims, so PermissiveAuthorizer satisfies it without conversion.

func (PermissiveAuthorizer) Can

Can always returns nil.

func (PermissiveAuthorizer) CanAccess

CanAccess always returns nil.

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 trio (logger/config/authz), 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, permissive authorizer, 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.

Jump to

Keyboard shortcuts

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