Silgi

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

OptionTypeDefaultDescription
contextOverrideRecord<string, unknown>Override or extend the base context
headersRecord<string, string>Mock request headers for context factory
timeoutnumber | null30000Default timeout in ms (null = none)

Per-call options (second argument)

OptionTypeDescription
signalAbortSignalAbortSignal for this call
contextRecord<string, unknown>Per-call context (merged over base)

How it works

createCaller uses the same compiled pipeline as handler():

  1. Compiles the router (shared cache — compiles once, reused by both handler() and createCaller)
  2. Creates a proxy that mirrors the router's nested structure
  3. 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?

  • Serverserve() and handler() for HTTP deployment
  • Middleware — guards and wraps that run in the pipeline
  • Typed Errors$errors() + fail() pattern

On this page