Documentation
¶
Overview ¶
Package tdd is a generics-based table-driven test library for Forge projects. It collapses the per-RPC test boilerplate (table struct, range loop, error-code assertion, mock construction) into a small set of reusable helpers so that scaffolded test files can be tiny shims that declare cases and let the library carry the iteration.
The library is dependency-light on purpose — it uses only the standard library plus connectrpc.com/connect (the same RPC dependency Forge projects already pull in). It does not introduce assertion frameworks, mock generators, or other runtime dependencies.
The four helpers ¶
TableRPC runs a slice of Case rows against a Connect handler function and asserts on either a happy-path response or an expected connect.Code error.
TableContract runs a slice of ContractCase rows against a contract.go-defined Service interface implementation. Each case supplies a closure that invokes one method; the helper compares the returned error and (optionally) the returned value.
E2EClient takes an *httptest.Server and a typed-client factory and returns a client wired to the server's URL, registering t.Cleanup(srv.Close) for you.
NewMock is a tiny option-based constructor for the Func-field mocks Forge generates from contract.go (mock_gen.go).
Standalone helpers ¶
AssertConnectError asserts that an error is a Connect error with the expected code, with a clear message on mismatch.
WithTimeout returns a context.Context with the given deadline and a cleanup func, suitable for table-row Setup hooks.
SetupMockDB returns a real-postgres *sql.DB (pkg/pgtest), registers cleanup, and is the same shape used by the bootstrap_testing scaffold.
See pkg/tdd/*_test.go for usage examples; the same patterns appear in the templates under internal/templates/{service,internal-package,test/e2e}.
Index ¶
- func AssertConnectError(t *testing.T, err error, want connect.Code)
- func AssertScaffoldStub(t *testing.T, err error)
- func E2EClient[Client any](t *testing.T, srv *httptest.Server, newClient func(url string) Client) Client
- func NewMock[T any](opts ...MockOption[T]) *T
- func RunRPCCases[Req, Resp any](t *testing.T, cases []RPCCase[Req, Resp], handler HandlerFunc[Req, Resp])
- func SetupMockDB(t *testing.T) *sql.DB
- func TableContract[T any](t *testing.T, impl T, cases []ContractCase)
- func TableRPC[Req, Resp any](t *testing.T, cases []Case[Req, Resp], handler HandlerFunc[Req, Resp])
- func WithTimeout(d time.Duration) (context.Context, context.CancelFunc)
- type Case
- type ContractCase
- type HandlerFunc
- type MockOption
- type RPCCase
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AssertConnectError ¶
AssertConnectError asserts that err is non-nil and that the client would read the given code for it. It is the canonical assertion helper used inside TableRPC and is exported for direct use in hand-written tests.
"Would read" is svcerr.Code: the code svcerr.Interceptor sends for err. A handler test calls the *Service method directly, with no interceptor in between, so the error it sees is whatever the handler returned — and a handler may return a bare svcerr kind (`return nil, svcerr.NotFound("x")`) because the interceptor wraps it. connect.CodeOf read that as Unknown, which made the documented handler shape untestable without a client. For a *connect.Error the two agree.
func AssertScaffoldStub ¶
AssertScaffoldStub asserts err is the error forge's generated handler stub returns — svcerr.ErrScaffoldStub. It backs Case.WantScaffoldStub and is exported for hand-written tests that drive a stub directly.
It accepts either identification, because which one is available depends on how far the error travelled. In process — a row calling the handler method directly — the chain is intact and errors.Is matches. Through a real Connect client the chain does not survive: the error is marshalled to the wire and rebuilt on the far side, so errors.Is is false and the reason METADATA is what arrives. Integration rows go over a client and unit rows do not, so both paths must work or the assertion would silently mean different things at the two tiers.
The failure message names what it found, because the interesting case is not "no error" but "a DIFFERENT error" — that is what an implemented handler produces, and the whole point of this row.
func E2EClient ¶
func E2EClient[Client any](t *testing.T, srv *httptest.Server, newClient func(url string) Client) Client
E2EClient takes an httptest.Server and a typed-client factory and returns a client wired to the server's URL. Cleanup of the server is registered via t.Cleanup, so the caller does not need to close it.
Typical usage with a generated Connect client:
client := tdd.E2EClient(t, srv, func(url string) myv1connect.MyServiceClient {
return myv1connect.NewMyServiceClient(http.DefaultClient, url)
})
The factory receives the live server URL (including scheme and port).
func NewMock ¶
func NewMock[T any](opts ...MockOption[T]) *T
NewMock constructs a zero-valued T and applies each option.
T is typically a Forge-generated MockService. The returned pointer is safe to assign into a Deps struct that expects the contract interface — Forge mocks have a *MockService method-set that satisfies their matching Service interface.
func RunRPCCases ¶
func RunRPCCases[Req, Resp any]( t *testing.T, cases []RPCCase[Req, Resp], handler HandlerFunc[Req, Resp], )
RunRPCCases is a function alias for TableRPC — see RPCCase. The codegen-emitted test shim uses RunRPCCases so the generated call site mirrors the migration skill's documentation. Hand-written tests may call either; they have identical behaviour.
func SetupMockDB ¶
SetupMockDB returns a fresh, isolated real-postgres *sql.DB suitable for hermetic unit tests. The DB and its underlying database are cleaned up via t.Cleanup, so the caller does not need to close it manually.
forge is postgres-pinned: the DB is a per-test database on the process-shared ephemeral postgres (pkg/pgtest — embedded-postgres by default, or the FORGE_TEST_POSTGRES_URL server). No driver blank-import is required; pgtest owns the driver. The first call in a process boots the shared server (downloading the pg binary on a fresh machine).
func TableContract ¶
func TableContract[T any](t *testing.T, impl T, cases []ContractCase)
TableContract runs a slice of ContractCase rows. Each row becomes a t.Run subtest. The helper invokes Setup, runs Call, and:
- if WantErr is set, asserts errors.Is(err, WantErr),
- else if Check is set, runs it on the returned value,
- else compares the returned value to Want via reflect.DeepEqual.
The impl parameter is unused at runtime — it exists so the call site reads naturally (TableContract(t, svc, cases)) and so the Go type system carries the implementation type into the closure capture, which is the most ergonomic shape we found for an "any method on T" table.
func TableRPC ¶
func TableRPC[Req, Resp any]( t *testing.T, cases []Case[Req, Resp], handler HandlerFunc[Req, Resp], )
TableRPC runs a slice of Case rows against a Connect handler. Each row becomes a t.Run subtest. The helper:
- calls Setup if present,
- invokes the handler with the row's request,
- asserts on WantErr (if set) or runs Check (if the call succeeded).
Subtests run with t.Parallel disabled by default so per-row Setup that touches shared state stays correct. Wrap the call site in a parallel outer test if you want concurrency; per-row parallelism is the caller's choice and not the library's default.
func WithTimeout ¶
WithTimeout returns a context.Context with the given deadline and a cleanup function that cancels it. Suitable for use inside a Case.Setup hook or directly as Case.Ctx.
tdd.Case[Req, Resp]{
Name: "slow", Req: ...,
Ctx: func() context.Context { ctx, _ := tdd.WithTimeout(2*time.Second); return ctx }(),
},
Cancel is exposed so tests that want eager cleanup can defer it; the returned context will already be canceled by Go's runtime once the timeout elapses.
Types ¶
type Case ¶
type Case[Req, Resp any] struct { // Name identifies the row; passed straight to t.Run. Name string // Req is the inbound *connect.Request used in the call. Req *connect.Request[Req] // WantErr, if non-zero, asserts the handler returned an error the // client reads with this code (svcerr.Code: a *connect.Error, or a bare // svcerr kind the interceptor wraps). The Check function is not // consulted in this case. WantErr connect.Code // WantScaffoldStub asserts the handler is still forge's generated // stub — i.e. nobody has implemented this RPC yet. It is what the // scaffold row forge emits alongside each stub sets, and it is // designed to STOP passing: implement the handler and the row fails, // which is the prompt to replace it with a real assertion. // // Do not reach for this in a hand-written row. It asserts an absence // of implementation, so it can only ever be true of code nobody has // written; to assert that a finished handler answers Unimplemented // (a forwarder, a feature-flagged path), use // WantErr: connect.CodeUnimplemented, which this deliberately does // not match. WantScaffoldStub bool // Check is invoked on a successful (err == nil) response so the test // can assert on the returned message. Optional. Check func(t *testing.T, resp *connect.Response[Resp]) // Setup runs before the handler is invoked. Use it to seed mocks or // populate per-row state. Cleanup should be registered via t.Cleanup // inside the closure. Setup func(t *testing.T) // Ctx, if non-nil, overrides the default context.Background() passed // to the handler. Use [WithTimeout] for a deadlined context. Ctx context.Context }
Case is a table-driven test row for a Connect RPC.
Req is the request proto type and Resp is the response proto type; Forge handlers receive *connect.Request[Req] and return *connect.Response[Resp], which matches the signature the helper expects.
Either WantErr or Check should be set — never both. WantErr asserts the handler returned a Connect error with the given code; Check is a caller-supplied function for asserting on the response (typically used for happy-path cases). If neither is set, the helper only verifies that the call did not return an error.
There is deliberately no "tolerate any outcome" mode: every row must be able to fail. Scaffold rows for not-yet-implemented handlers set WantScaffoldStub — such a row self-destructs (goes red) the moment the handler is implemented, forcing it to be rewritten with a real Check / WantErr assertion.
type ContractCase ¶
type ContractCase struct {
// Name identifies the row; passed straight to t.Run.
Name string
// Call invokes one method on the contract implementation. The
// returned value is compared against Want using reflect.DeepEqual
// when WantErr is nil.
Call func() (any, error)
// Want is the expected return value (compared with reflect.DeepEqual).
// Ignored when WantErr is set or Check is non-nil.
Want any
// WantErr, if non-nil, asserts that Call returned an error matching
// it via errors.Is. Use a sentinel error or a wrapped error.
WantErr error
// Check is an alternative to Want — it runs on the returned value
// after a successful call and lets the test assert custom predicates.
Check func(t *testing.T, got any)
// Setup runs before Call. Use it to wire mocks or seed state for
// this row. Cleanup should be registered via t.Cleanup.
Setup func(t *testing.T)
}
ContractCase is a table-driven test row for a single contract-method invocation. Each row supplies a Call closure that invokes one method on the contract implementation. The closure returns (any, error) so a single ContractCase type can drive any method shape — multi-return methods adapt by packing into a struct, single-return methods return the value directly.
type HandlerFunc ¶
type HandlerFunc[Req, Resp any] func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)
HandlerFunc is the Connect RPC handler signature TableRPC drives.
type MockOption ¶
type MockOption[T any] func(*T)
MockOption configures a Forge-generated Func-field mock.
Forge's mock_gen.go produces structs of the form
type MockService struct {
DoXFunc func(...) (...)
DoYFunc func(...) (...)
}
MockOption[T] is a functional option that mutates such a struct. Use it with NewMock to build a mock at the call site:
m := tdd.NewMock(
func(m *email.MockService) { m.SendFunc = func(...) error { return nil } },
)
MockOption is a plain function alias so callers can write inline closures without naming the option type, which keeps test files short.