tRPC with Next.js App Router Testing: Server Context and Router Tests

tRPC with Next.js App Router Testing: Server Context and Router Tests

tRPC's createCaller() lets you call router procedures directly in tests — no HTTP server, no fetch mocking, no supertest. You inject the context (auth session, database client) yourself, which means you can test authenticated and unauthenticated flows, input validation, middleware enforcement, and error handling with plain function calls. This post shows exactly how to do that in a Next.js 14+ App Router project.

Key Takeaways

createCaller() is the right tool for server-side tRPC tests. It bypasses HTTP and calls procedures as regular async functions. You get real TypeScript types, real Zod validation, and real error throws — without a network layer.

Inject context explicitly per test. The test controls the context. Pass a null session to test unauthenticated behavior. Pass a mock db to control data. Never let tests share context state.

TRPCError is thrown, not returned. Protected procedures throw TRPCError({ code: 'UNAUTHORIZED' }) — not return a 401 object. Test this with expect().rejects.toThrow() or try/catch.

Test middleware by testing procedures that use it. Don't test protectedProcedure middleware in isolation — test a real procedure that's declared with it. The middleware's effect is observable through the procedure's behavior.

Zod validation errors surface as TRPCError with code BAD_REQUEST. Input that violates a .input() schema throws a TRPCError before your procedure handler runs. Test this the same way you test authorization errors.

Project Structure

Assume a standard tRPC + Next.js App Router setup:

src/
  server/
    trpc.ts          # initTRPC, context type, base procedures
    context.ts       # createContext for App Router
    routers/
      users.ts
      posts.ts
      _app.ts        # appRouter
  app/
    api/
      trpc/
        [trpc]/
          route.ts   # HTTP adapter

Defining the tRPC Foundation

// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { z } from 'zod'
import type { Session } from 'next-auth'
import type { PrismaClient } from '@prisma/client'

export interface Context {
  session: Session | null
  db: PrismaClient
}

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

export const router = t.router
export const publicProcedure = t.procedure

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Must be logged in' })
  }
  return next({ ctx: { ...ctx, session: ctx.session } })
})

export const adminProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }
  if (ctx.session.user.role !== 'admin') {
    throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' })
  }
  return next({ ctx: { ...ctx, session: ctx.session } })
})

A Sample Router Under Test

// src/server/routers/users.ts
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure, adminProcedure } from '../trpc'

export const usersRouter = router({
  me: protectedProcedure.query(({ ctx }) => {
    return { id: ctx.session.user.id, email: ctx.session.user.email }
  }),

  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
    }),

  create: adminProcedure
    .input(z.object({
      email: z.string().email(),
      name: z.string().min(2),
      role: z.enum(['user', 'admin']).default('user'),
    }))
    .mutation(async ({ input, ctx }) => {
      return ctx.db.user.create({ data: input })
    }),

  list: protectedProcedure
    .input(z.object({
      page: z.number().int().min(1).default(1),
      limit: z.number().int().min(1).max(100).default(20),
    }))
    .query(async ({ input, ctx }) => {
      const offset = (input.page - 1) * input.limit
      return ctx.db.user.findMany({ skip: offset, take: input.limit })
    }),
})

Test Helpers: Context Factories

Build typed context factories for each auth state:

// test/helpers/context.ts
import { vi } from 'vitest'
import type { Context } from '../../src/server/trpc'
import type { Session } from 'next-auth'

export function mockDb(overrides: Partial<Record<string, unknown>> = {}) {
  return {
    user: {
      findUnique: vi.fn(),
      findMany: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    },
    post: {
      findUnique: vi.fn(),
      findMany: vi.fn(),
      create: vi.fn(),
    },
    ...overrides,
  } as unknown as Context['db']
}

export function unauthenticatedCtx(dbOverrides = {}): Context {
  return { session: null, db: mockDb(dbOverrides) }
}

export function authenticatedCtx(
  userOverrides: Partial<Session['user']> = {},
  dbOverrides = {}
): Context {
  return {
    session: {
      user: { id: 'user-1', email: 'user@test.com', role: 'user', ...userOverrides },
      expires: new Date(Date.now() + 3600 * 1000).toISOString(),
    },
    db: mockDb(dbOverrides),
  }
}

export function adminCtx(dbOverrides = {}): Context {
  return authenticatedCtx({ id: 'admin-1', email: 'admin@test.com', role: 'admin' }, dbOverrides)
}

Testing with createCaller()

// test/routers/users.test.ts
import { describe, it, expect, vi } from 'vitest'
import { TRPCError } from '@trpc/server'
import { appRouter } from '../../src/server/routers/_app'
import { unauthenticatedCtx, authenticatedCtx, adminCtx } from '../helpers/context'

function createCaller(ctx: ReturnType<typeof unauthenticatedCtx>) {
  return appRouter.createCaller(ctx)
}

describe('users.me', () => {
  it('returns current user for authenticated requests', async () => {
    const ctx = authenticatedCtx({ id: 'user-42', email: 'me@test.com' })
    const caller = createCaller(ctx)

    const result = await caller.users.me()

    expect(result).toEqual({ id: 'user-42', email: 'me@test.com' })
  })

  it('throws UNAUTHORIZED for unauthenticated requests', async () => {
    const ctx = unauthenticatedCtx()
    const caller = createCaller(ctx)

    await expect(caller.users.me()).rejects.toThrow(TRPCError)
    await expect(caller.users.me()).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
  })
})

Testing Input Validation

describe('users.getById', () => {
  it('returns user when found', async () => {
    const mockUser = { id: 'a1b2c3d4-e5f6-4789-abcd-ef1234567890', email: 'found@test.com' }
    const ctx = unauthenticatedCtx()
    vi.mocked(ctx.db.user.findUnique).mockResolvedValue(mockUser as any)

    const caller = createCaller(ctx)
    const result = await caller.users.getById({ id: mockUser.id })

    expect(result).toEqual(mockUser)
    expect(ctx.db.user.findUnique).toHaveBeenCalledWith({
      where: { id: mockUser.id },
    })
  })

  it('throws NOT_FOUND when user does not exist', async () => {
    const ctx = unauthenticatedCtx()
    vi.mocked(ctx.db.user.findUnique).mockResolvedValue(null)

    const caller = createCaller(ctx)

    await expect(
      caller.users.getById({ id: 'a1b2c3d4-e5f6-4789-abcd-ef1234567890' })
    ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'User not found' })
  })

  it('throws BAD_REQUEST for non-UUID input', async () => {
    const ctx = unauthenticatedCtx()
    const caller = createCaller(ctx)

    // Zod validates input before the handler runs
    await expect(
      caller.users.getById({ id: 'not-a-uuid' })
    ).rejects.toThrow() // TRPCError with code BAD_REQUEST

    // db should never be called for invalid input
    expect(ctx.db.user.findUnique).not.toHaveBeenCalled()
  })

  it('throws BAD_REQUEST for missing id', async () => {
    const ctx = unauthenticatedCtx()
    const caller = createCaller(ctx)

    await expect(
      // @ts-expect-error — intentional runtime test of missing field
      caller.users.getById({})
    ).rejects.toThrow()
  })
})

Testing Middleware (protectedProcedure and adminProcedure)

describe('users.create — admin only', () => {
  const validInput = { email: 'new@test.com', name: 'New User', role: 'user' as const }

  it('allows admin users to create', async () => {
    const created = { id: 'new-id', ...validInput }
    const ctx = adminCtx()
    vi.mocked(ctx.db.user.create).mockResolvedValue(created as any)

    const caller = createCaller(ctx)
    const result = await caller.users.create(validInput)

    expect(result).toEqual(created)
    expect(ctx.db.user.create).toHaveBeenCalledWith({ data: validInput })
  })

  it('throws FORBIDDEN for non-admin authenticated users', async () => {
    const ctx = authenticatedCtx({ role: 'user' })
    const caller = createCaller(ctx)

    await expect(caller.users.create(validInput)).rejects.toMatchObject({
      code: 'FORBIDDEN',
      message: 'Admin access required',
    })

    expect(ctx.db.user.create).not.toHaveBeenCalled()
  })

  it('throws UNAUTHORIZED for unauthenticated users', async () => {
    const ctx = unauthenticatedCtx()
    const caller = createCaller(ctx)

    await expect(caller.users.create(validInput)).rejects.toMatchObject({
      code: 'UNAUTHORIZED',
    })
  })

  it('validates input even for admin users', async () => {
    const ctx = adminCtx()
    const caller = createCaller(ctx)

    await expect(
      caller.users.create({ email: 'not-an-email', name: 'x', role: 'user' })
    ).rejects.toThrow()

    expect(ctx.db.user.create).not.toHaveBeenCalled()
  })
})

Testing Pagination

describe('users.list', () => {
  it('queries with correct offset and limit', async () => {
    const ctx = authenticatedCtx()
    vi.mocked(ctx.db.user.findMany).mockResolvedValue([])

    const caller = createCaller(ctx)
    await caller.users.list({ page: 3, limit: 10 })

    expect(ctx.db.user.findMany).toHaveBeenCalledWith({ skip: 20, take: 10 })
  })

  it('uses default page=1 and limit=20', async () => {
    const ctx = authenticatedCtx()
    vi.mocked(ctx.db.user.findMany).mockResolvedValue([])

    const caller = createCaller(ctx)
    await caller.users.list({})

    expect(ctx.db.user.findMany).toHaveBeenCalledWith({ skip: 0, take: 20 })
  })

  it('rejects limit above 100', async () => {
    const ctx = authenticatedCtx()
    const caller = createCaller(ctx)

    await expect(caller.users.list({ page: 1, limit: 101 })).rejects.toThrow()
    expect(ctx.db.user.findMany).not.toHaveBeenCalled()
  })
})

Testing Error Message Safety

Errors exposed to clients should never leak internal details:

describe('error message safety', () => {
  it('does not expose database error details to clients', async () => {
    const ctx = unauthenticatedCtx()
    vi.mocked(ctx.db.user.findUnique).mockRejectedValue(
      new Error('Connection refused: postgres://internal-host:5432/db')
    )

    const caller = createCaller(ctx)

    try {
      await caller.users.getById({ id: 'a1b2c3d4-e5f6-4789-abcd-ef1234567890' })
      expect.fail('Should have thrown')
    } catch (err) {
      if (err instanceof TRPCError) {
        expect(err.message).not.toContain('postgres://')
        expect(err.message).not.toContain('internal-host')
      }
    }
  })
})

What to Test vs. What to Skip

Test:

  • Each procedure's happy path with a realistic context
  • UNAUTHORIZED for protected procedures with null session
  • FORBIDDEN for admin procedures with non-admin session
  • NOT_FOUND for queries that return null from the database
  • BAD_REQUEST triggered by Zod validation — especially boundary values (min/max, wrong type)
  • That the database mock was called with the correct arguments
  • Error messages don't leak internal connection strings or stack traces

Skip:

  • The HTTP transport layer — createCaller() doesn't use it, and @trpc/server's HTTP handling has its own test suite
  • Testing that Zod validates correctly — test that the procedure rejects invalid input, not Zod's internal logic
  • Testing TypeScript types at runtime — use tsc --noEmit for that
  • Mocking the tRPC framework itself — trust createCaller() to call your handler correctly; test what your handler does

createCaller() gives you the cleanest possible interface for testing tRPC logic. Use it for every procedure, keep contexts immutable within a test, and mock only at the database boundary.

Read more