Testing
Test your procedures directly with createCaller — no HTTP server needed.
createCaller lets you call procedures directly without starting an HTTP server. Input validation, output validation, guards, and the full compiled pipeline all run — just like a real request, minus the HTTP overhead.
Quick start
import { } from 'silgi'
import { } from 'zod'
const = ({
: () => ({ : getDB() }),
})
const = .({
: {
:
.(.({ : .().() }))
.(({ , }) => ..users.findMany({ : . })),
},
})
// Create a caller — no server needed
const = .()
const = await ..({ : 10 })The caller is fully typed — autocomplete works for procedure names, input shapes, and return types.
With Vitest
import { , , } from 'vitest'
import { , } from './server/rpc/router'
('users', () => {
const = .createCaller()
('lists users', async () => {
const = await .users.list({ : 5 })
().(5)
})
('rejects invalid input', async () => {
await (.users.list({ : -1 }))..()
})
})Context override
Override or extend the base context for testing. Useful for injecting mock data or bypassing auth:
// Test with a specific user context
const = s.createCaller(appRouter, {
: { : { : 1, : 'admin' } },
})
// Test with mock database
const = s.createCaller(appRouter, {
: {
: { : { : () => [{ : 1, : 'Mock' }] } },
},
})Per-call options
Pass signal or context as a second argument to any procedure call:
const = s.createCaller(appRouter)
// Per-call context — useful for testing different auth states
await .users.create(
{ : 'Alice' },
{
: { : 'valid-jwt' },
},
)
// Per-call signal — useful for testing cancellation
const = new ()
await .users.list({}, { : . })Testing guards
Guards run in the compiled pipeline, just like in HTTP requests. Test them by providing or omitting the required context:
import { } from 'silgi'
// Without auth — guard rejects
const = s.createCaller(appRouter)
try {
await .admin.deleteUser({ : 1 })
} catch () {
expect().toBeInstanceOf()
expect(.code).toBe('UNAUTHORIZED')
}
// With auth — guard passes
const = s.createCaller(appRouter, {
: { : 'admin-token' },
})
await .admin.deleteUser({ : 1 })Testing typed errors
Procedures using $errors() + fail() throw SilgiError with the declared code:
import { } from 'silgi'
const = s.createCaller(appRouter)
try {
await .users.get({ : 999 })
} catch () {
expect().toBeInstanceOf()
expect(.code).toBe('NOT_FOUND')
expect(.status).toBe(404)
}Options reference
createCaller options
| Option | Type | Default | Description |
|---|---|---|---|
contextOverride | Record<string, unknown> | — | Override or extend the base context |
headers | Record<string, string> | — | Mock request headers for context factory |
timeout | number | null | 30000 | Default timeout in ms (null = none) |
Per-call options (second argument)
| Option | Type | Description |
|---|---|---|
signal | AbortSignal | AbortSignal for this call |
context | Record<string, unknown> | Per-call context (merged over base) |
How it works
createCaller uses the same compiled pipeline as handler():
- Compiles the router (shared cache — compiles once, reused by both
handler()andcreateCaller) - Creates a proxy that mirrors the router's nested structure
- On each call: resolves context → injects route params → runs the compiled pipeline (guards + input validation + resolve + output validation)
The only difference from an HTTP request: no serialization, no HTTP headers, no response construction. Everything else — validation, guards, error codes — behaves identically.
createCaller is the recommended way to test procedures. It runs the full pipeline including guards and validation,
catching bugs that unit-testing the resolve function alone would miss.
What's next?
- Server —
serve()andhandler()for HTTP deployment - Middleware — guards and wraps that run in the pipeline
- Typed Errors —
$errors()+fail()pattern