Testing tRPC Routers: createCaller Pattern and End-to-End Type-Safe Tests

Testing tRPC Routers: createCaller Pattern and End-to-End Type-Safe Tests

tRPC procedures are TypeScript functions — they accept typed input, receive a context, and return typed output. The createCaller pattern lets you call tRPC procedures directly in tests without an HTTP server. This gives you full type safety, fast execution, and the ability to inject test contexts (mocked databases, authenticated users) without any network overhead.

Key Takeaways

createCaller is the right testing primitive. It calls procedures as plain TypeScript functions, bypassing HTTP. You get full type checking on inputs and outputs, errors are real exceptions, and there's no HTTP server to manage.

Inject context to control auth and dependencies. The context factory (createContext) is the entry point for test doubles. Pass a mock database client or a fake user session directly.

Test the procedure, not the router structure. Test that router.user.getById({ id: '1' }) returns the right user — not that the router has a user namespace.

Procedures throw TRPCError on validation failures. Catch it and check error.code and error.message in tests. Don't catch generic Error.

Integration tests should hit a real HTTP server. Use createServer with supertest for tests that go through the HTTP adapter — these catch serialization issues and middleware problems that createCaller won't see.

Testing Approach Overview

tRPC gives you three testing options:

  1. createCaller — call procedures as TypeScript functions (unit tests, fast)
  2. httpBatchLink with supertest — call procedures through HTTP in tests (integration tests)
  3. End-to-end with Playwright/Cypress — test the full stack in a browser

This guide focuses on the first two. For most procedures, createCaller is all you need. Use the HTTP approach for procedures where middleware behavior, request parsing, or serialization matters.

Project Structure Assumption

src/
  server/
    routers/
      user.ts
      post.ts
    trpc.ts       // router + procedure builders
    context.ts    // context factory
  tests/
    user.test.ts
    post.test.ts
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import type { Context } from './context';

const t = initTRPC.context<Context>().create();

export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(/* auth middleware */);
// server/context.ts
import type { PrismaClient } from '@prisma/client';

export interface Context {
  db: PrismaClient;
  user: { id: string; role: string } | null;
}

Unit Testing with createCaller

Basic Procedure Test

// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';

export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input, ctx }) => {
      const user = await ctx.db.user.findUnique({ where: { id: input.id } });
      if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
      return user;
    }),

  list: publicProcedure
    .input(z.object({ limit: z.number().int().min(1).max(100).default(20) }))
    .query(async ({ input, ctx }) => {
      return ctx.db.user.findMany({ take: input.limit });
    }),
});
// tests/user.test.ts
import { userRouter } from '../server/routers/user';
import { TRPCError } from '@trpc/server';

// Mock database client
const mockDb = {
  user: {
    findUnique: jest.fn(),
    findMany: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
  },
};

function createTestContext(overrides: Partial<Context> = {}): Context {
  return {
    db: mockDb as any,
    user: null,
    ...overrides,
  };
}

describe('userRouter.getById', () => {
  let caller: ReturnType<typeof userRouter.createCaller>;

  beforeEach(() => {
    caller = userRouter.createCaller(createTestContext());
    jest.clearAllMocks();
  });

  it('returns a user when found', async () => {
    const mockUser = { id: 'user-uuid-1', name: 'Alice', email: 'alice@example.com' };
    mockDb.user.findUnique.mockResolvedValue(mockUser);

    const result = await caller.getById({ id: 'user-uuid-1' });

    expect(result).toEqual(mockUser);
    expect(mockDb.user.findUnique).toHaveBeenCalledWith({
      where: { id: 'user-uuid-1' },
    });
  });

  it('throws NOT_FOUND when user does not exist', async () => {
    mockDb.user.findUnique.mockResolvedValue(null);

    await expect(caller.getById({ id: 'user-uuid-1' })).rejects.toThrow(TRPCError);
    await expect(caller.getById({ id: 'user-uuid-1' })).rejects.toMatchObject({
      code: 'NOT_FOUND',
      message: 'User not found',
    });
  });

  it('throws on invalid UUID input', async () => {
    await expect(caller.getById({ id: 'not-a-uuid' })).rejects.toThrow(TRPCError);
    await expect(caller.getById({ id: 'not-a-uuid' })).rejects.toMatchObject({
      code: 'BAD_REQUEST',
    });
  });
});

Testing Protected Procedures

Protected procedures require an authenticated user in context:

// server/routers/post.ts
export const postRouter = router({
  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      content: z.string(),
    }))
    .mutation(async ({ input, ctx }) => {
      return ctx.db.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ input, ctx }) => {
      const post = await ctx.db.post.findUnique({ where: { id: input.id } });
      if (!post) throw new TRPCError({ code: 'NOT_FOUND' });
      if (post.authorId !== ctx.user.id) throw new TRPCError({ code: 'FORBIDDEN' });
      return ctx.db.post.delete({ where: { id: input.id } });
    }),
});
describe('postRouter.create', () => {
  it('creates a post for the authenticated user', async () => {
    const authenticatedUser = { id: 'user-1', role: 'user' };
    const caller = postRouter.createCaller(
      createTestContext({ user: authenticatedUser })
    );

    const mockPost = { id: 'post-1', title: 'Hello', content: 'World', authorId: 'user-1' };
    mockDb.post.create.mockResolvedValue(mockPost);

    const result = await caller.create({ title: 'Hello', content: 'World' });

    expect(result).toEqual(mockPost);
    expect(mockDb.post.create).toHaveBeenCalledWith({
      data: { title: 'Hello', content: 'World', authorId: 'user-1' },
    });
  });

  it('throws UNAUTHORIZED when not authenticated', async () => {
    const caller = postRouter.createCaller(createTestContext({ user: null }));

    await expect(
      caller.create({ title: 'Hello', content: 'World' })
    ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
  });
});

describe('postRouter.delete', () => {
  it('prevents deleting another user\'s post', async () => {
    const caller = postRouter.createCaller(
      createTestContext({ user: { id: 'user-2', role: 'user' } })
    );

    mockDb.post.findUnique.mockResolvedValue({
      id: 'post-1',
      authorId: 'user-1',  // different user
    });

    await expect(caller.delete({ id: 'post-1' })).rejects.toMatchObject({
      code: 'FORBIDDEN',
    });
  });
});

Testing Middleware

If your middleware adds data to context (like resolving the current user from a session token), test it separately:

// server/middleware/auth.ts
export const authMiddleware = t.middleware(async ({ ctx, next }) => {
  if (!ctx.session?.userId) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  const user = await ctx.db.user.findUnique({ where: { id: ctx.session.userId } });
  if (!user) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not found' });
  return next({ ctx: { ...ctx, user } });
});
describe('authMiddleware', () => {
  it('throws UNAUTHORIZED when no session', async () => {
    const testProcedure = t.procedure
      .use(authMiddleware)
      .query(() => 'authenticated');

    const testRouter = router({ test: testProcedure });
    const caller = testRouter.createCaller({ db: mockDb as any, session: null });

    await expect(caller.test()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
  });
});

Integration Testing with HTTP

For procedures that need full HTTP testing (cookie handling, HTTP headers, middleware):

import { createHTTPServer } from '@trpc/server/adapters/standalone';
import request from 'supertest';
import { appRouter } from '../server/router';
import { createContext } from '../server/context';

describe('tRPC HTTP integration', () => {
  let server: ReturnType<typeof createHTTPServer>;

  beforeAll(() => {
    server = createHTTPServer({
      router: appRouter,
      createContext,
    });
    server.listen(0);  // random port
  });

  afterAll(() => {
    server.server.close();
  });

  it('returns user via HTTP', async () => {
    const response = await request(server.server)
      .get('/user.getById?input={"id":"user-uuid-1"}')
      .expect(200);

    expect(response.body.result.data).toMatchObject({ id: 'user-uuid-1' });
  });
});

HTTP integration tests are slower but catch serialization, CORS, and adapter-specific behavior.

Type Safety in Tests

One of tRPC's core benefits is type-safe tests. The TypeScript compiler will warn you if you pass the wrong input or assert the wrong output type:

// TypeScript error: 'id' expects string, not number
await caller.getById({ id: 123 });  // TS error at write time

// TypeScript error: 'result.nonexistent' does not exist
const result = await caller.getById({ id: 'user-1' });
console.log(result.nonexistent);  // TS error at write time

This is the key advantage over REST API testing — your tests are checked by the compiler before they run.

Running tRPC Tests

npx jest src/tests/        # run all procedure tests
npx jest --testPathPattern=user    <span class="hljs-comment"># user procedures only
npx jest --coverage --collectCoverageFrom=<span class="hljs-string">'src/server/**/*.ts'

Production Monitoring

Unit tests verify procedure logic in isolation. They don't verify that your deployed tRPC API actually works from a client's perspective.

HelpMeTest tests your live API 24/7 — checking that real HTTP requests return the expected responses, catching regressions in deployed code before users notice. Start free with 10 monitored tests.

Read more