Testing
Testing at the right granularity with real infrastructure where it matters .
Test at the right granularity
Domain logic gets unit tested with a real database but a no-op outbox . API handlers get integration tested with the full stack . Each layer tests what it owns .
Domain tests verify business logic : not found , already exists , precondition failures . The domain never sees unauthenticated or invalid argument errors , those are caught by interceptors before the handler is called .
API tests verify the full request lifecycle : interceptor rejections , error mapping , success scenarios . They exercise the real path from request to response .
Two setup functions
API tests use two setup functions to avoid spinning up testcontainers when they are not needed :
setupHandler(t)is lightweight , no database . It wires a panic service that explodes if a request ever reaches it . Used for tests where interceptors reject before the handler is called : unauthenticated , invalid argument , permission denied .setupHandlerWithDB(t)spins up a real postgres via testcontainers . Used for tests that reach the domain layer : not found , already exists , success .
func setupHandler(t *testing.T) (*testClients[contentv1connect.ContentServiceClient], context.Context) {
t.Helper()
handler := apicontentv1.New(apicontentv1.Dependencies{Service: &panicService{}})
return startServer(t, handler)
}
func setupHandlerWithDB(t *testing.T) (*testClients[contentv1connect.ContentServiceClient], context.Context) {
t.Helper()
ctx := context.Background()
connStr := testkit.SetupPostgres(ctx, t)
// ... wire real pool, queries, cache, no-op outbox
handler := apicontentv1.New(apicontentv1.Dependencies{Service: svc})
return startServer(t, handler)
}
Each parent test calls the setup function it needs . Subtests within a parent share the setup and run in parallel via t.Parallel() .
One file per operation , one file per route
Setup files contain only setup . No Test* functions .
service_test.go: domain test setup ,setupService(t)handler_test.go: API test setup ,setupHandler(t),setupHandlerWithDB(t), access level helpers
Test files mirror the source :
op_create_test.gomirrorsop_create.goroute_create_content_test.gomirrorsroute_create_content.go
Each route test file has two parent tests :
func TestCreateContent_Errors(t *testing.T) {
clients, ctx := setupHandler(t) // no DB
t.Run("unauthenticated — no token", func(t *testing.T) {
t.Parallel()
_, err := clients.anonymous.CreateContent(ctx, connect.NewRequest(validCreateRequest()))
require.Error(t, err)
assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err))
})
t.Run("invalid argument — empty title", func(t *testing.T) {
t.Parallel()
// ...
})
}
func TestCreateContent_Success(t *testing.T) {
clients, ctx := setupHandlerWithDB(t) // with DB
t.Run("not found — nonexistent ID", func(t *testing.T) {
t.Parallel()
// ...
})
t.Run("creates with required fields", func(t *testing.T) {
t.Parallel()
resp, err := clients.standard.CreateContent(ctx, connect.NewRequest(validCreateRequest()))
require.NoError(t, err)
assert.NotEmpty(t, resp.Msg.Content.Id)
})
}
Access level testing
Every API has callers with different permissions . Tests need to verify that each caller sees the right behaviour : rejection , success , or something in between . Rather than constructing auth headers ad hoc in each test , the setup returns four pre-configured clients , one per access level :
type testClients[T any] struct {
anonymous T // no auth token , expects Unauthenticated
standard T // regular user , can create / read / update
admin T // admin user , can also delete
elevated T // system-level , service-to-service calls
}
Each client injects its own bearer token via an interceptor . The server-side auth interceptor decodes the token and sets the caller identity . Tests pick the client that matches the scenario :
- Every RPC tests
clients.anonymousand expectsUnauthenticated. - Standard operations (create , get , list , update) test success via
clients.standard. - Admin-only operations (delete , bulk) test
clients.standardand expectPermissionDenied, then test success viaclients.admin. - System-level operations test
clients.adminand expectPermissionDenied, then test success viaclients.elevated.
The access level a given RPC requires is domain-specific , but the testing pattern is always the same .
assert vs require
Use require when failure would cause a panic on the next line . Use assert for everything else .
resp, err := client.GetContent(ctx, req)
require.NoError(t, err) // panic guard — resp would be nil
assert.Equal(t, "expected", resp.Msg.Content.Title) // safe to continue
require stops the test immediately . assert records the failure and continues . Rule of thumb : require.NoError after create and setup calls , assert for the actual assertions .