Testing tRPC Components: From Unit to E2E Tests
tRPC has become a go-to solution for building type-safe APIs in TypeScript applications. By eliminating the need for code generation and shared schema definitions, tRPC allows developers to build end-to-end type-safe applications with minimal boilerplate. However, like any architectural choice, tRPC introduces its own testing challenges. In this tutorial, we will explore how to test tRPC components thoroughly — from individual procedures to full end-to-end scenarios — so you can ship with confidence.
What Is tRPC Testing?
tRPC testing refers to the practice of validating the behavior of your tRPC routers, procedures, middleware, and the client-server interactions they enable. Because tRPC procedures are essentially TypeScript functions that run on the server, they can be tested at multiple levels of granularity. You can unit test individual procedures in isolation, integration test routers with their dependencies, or perform end-to-end (E2E) tests that exercise the entire stack from the client to the database.
The key insight is that tRPC's design makes testing remarkably approachable. Since procedures are plain functions, you do not need to spin up an HTTP server to test them. You can invoke them directly, mock their context, and assert on their outputs. This flexibility lets you choose the right level of testing for each scenario.
Why Testing tRPC Matters
While tRPC provides compile-time type safety, types alone cannot guarantee runtime correctness. A procedure might type-check perfectly but still return wrong data, throw unexpected errors, or fail to enforce authorization rules. Testing fills this gap by verifying actual behavior. Here are the core reasons testing tRPC components is essential:
- Runtime validation: Zod schemas used in tRPC procedures validate inputs at runtime, but you need tests to confirm those validations behave as expected with real data.
- Authorization logic: Middleware that checks user roles and permissions must be tested to prevent security vulnerabilities.
- Business logic correctness: Procedures often contain complex business rules that types cannot express. Tests ensure these rules are implemented correctly.
- Regression prevention: As your API grows, tests catch breaking changes before they reach production.
- Documentation: Well-written tests serve as executable documentation, showing how procedures are expected to be used.
Setting Up the Testing Environment
Before diving into tests, let us set up a sample tRPC project structure and install the necessary testing dependencies. We will use Vitest as our test runner because of its speed and native TypeScript support, but the concepts apply equally to Jest.
First, install the testing dependencies:
npm install -D vitest @vitest/coverage-v8 supertest
Next, consider a typical tRPC router structure. Here is a simplified example of a blog application with a post router:
// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
export interface Context {
user: { id: string; role: 'admin' | 'user' } | null;
db: DbClient;
}
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
const isAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.user || ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx });
});
export const adminProcedure = publicProcedure.use(isAdmin);
// src/server/routers/post.ts
import { z } from 'zod';
import { router, publicProcedure, adminProcedure } from '../trpc';
export const postRouter = router({
list: publicProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return ctx.db.post.findMany({ take: input.limit });
}),
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.id } });
if (!post) throw new TRPCError({ code: 'NOT_FOUND' });
return post;
}),
create: adminProcedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: { ...input, authorId: ctx.user!.id },
});
}),
});
// src/server/root.ts
import { router } from './trpc';
import { postRouter } from './routers/post';
export const appRouter = router({
post: postRouter,
});
export type AppRouter = typeof appRouter;
Unit Testing Individual Procedures
Unit tests focus on testing individual procedures in isolation. The strategy is to construct a mock context, call the procedure directly, and assert on the result. tRPC provides a convenient way to invoke procedures programmatically using the createCaller function.
The createCaller method creates a caller proxy that lets you invoke procedures as if you were a client, but without any HTTP layer. This is the foundation of unit testing in tRPC.
// tests/unit/postRouter.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { postRouter } from '../../src/server/routers/post';
function createMockContext(overrides = {}) {
return {
user: { id: 'user-1', role: 'admin' as const },
db: {
post: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
},
...overrides,
};
}
describe('postRouter.list', () => {
it('should return posts with default limit', async () => {
const ctx = createMockContext();
const mockPosts = [
{ id: '1', title: 'First Post', content: 'Hello' },
];
ctx.db.post.findMany.mockResolvedValue(mockPosts);
const caller = postRouter.createCaller(ctx);
const result = await caller.post.list({ limit: 10 });
expect(result).toEqual(mockPosts);
expect(ctx.db.post.findMany).toHaveBeenCalledWith({ take: 10 });
});
it('should enforce maximum limit of 100', async () => {
const ctx = createMockContext();
const caller = postRouter.createCaller(ctx);
await expect(caller.post.list({ limit: 101 })).rejects.toThrow();
});
});
Notice how we construct a mock context with Vitest's vi.fn() to create controllable mock functions. This lets us assert exactly what arguments were passed to the database layer without needing a real database.
Testing Input Validation
One of the most important things to test is input validation. tRPC uses Zod schemas to validate inputs, and you should verify that both valid and invalid inputs are handled correctly. When validation fails, tRPC throws a TRPCError with the code BAD_REQUEST.
// tests/unit/postRouter.validation.test.ts
import { describe, it, expect } from 'vitest';
import { postRouter } from '../../src/server/routers/post';
function createMockContext() {
return {
user: { id: 'user-1', role: 'admin' as const },
db: {
post: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
},
};
}
describe('postRouter.create input validation', () => {
const caller = postRouter.createCaller(createMockContext());
it('should reject empty title', async () => {
await expect(
caller.post.create({ title: '', content: 'Valid content' })
).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
});
it('should reject title longer than 200 characters', async () => {
await expect(
caller.post.create({ title: 'a'.repeat(201), content: 'Valid' })
).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
});
it('should reject missing content', async () => {
await expect(
caller.post.create({ title: 'Valid Title', content: '' })
).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
});
it('should accept valid input', async () => {
const ctx = createMockContext();
ctx.db.post.create.mockResolvedValue({
id: '1',
title: 'Valid Title',
content: 'Valid content',
authorId: 'user-1',
});
const validCaller = postRouter.createCaller(ctx);
const result = await validCaller.post.create({
title: 'Valid Title',
content: 'Valid content',
});
expect(result.title).toBe('Valid Title');
});
});
Testing Middleware and Authorization
Authorization logic is critical to get right. Testing middleware ensures that only authorized users can access protected procedures. You should test both the positive case (authorized user succeeds) and the negative case (unauthorized user is rejected).
// tests/unit/postRouter.auth.test.ts
import { describe, it, expect, vi } from 'vitest';
import { postRouter } from '../../src/server/routers/post';
describe('postRouter.create authorization', () => {
it('should reject unauthenticated users', async () => {
const ctx = {
user: null,
db: { post: { create: vi.fn() } },
};
const caller = postRouter.createCaller(ctx);
await expect(
caller.post.create({ title: 'Title', content: 'Content' })
).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
expect(ctx.db.post.create).not.toHaveBeenCalled();
});
it('should reject non-admin users', async () => {
const ctx = {
user: { id: 'user-2', role: 'user' as const },
db: { post: { create: vi.fn() } },
};
const caller = postRouter.createCaller(ctx);
await expect(
caller.post.create({ title: 'Title', content: 'Content' })
).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
it('should allow admin users', async () => {
const ctx = {
user: { id: 'admin-1', role: 'admin' as const },
db: {
post: {
create: vi.fn().mockResolvedValue({
id: '1',
title: 'Title',
content: 'Content',
authorId: 'admin-1',
}),
},
},
};
const caller = postRouter.createCaller(ctx);
const result = await caller.post.create({
title: 'Title',
content: 'Content',
});
expect(result.authorId).toBe('admin-1');
expect(ctx.db.post.create).toHaveBeenCalledWith({
data: {
title: 'Title',
content: 'Content',
authorId: 'admin-1',
},
});
});
});
Testing Error Handling
Procedures should handle errors gracefully and return appropriate tRPC error codes. Testing error scenarios ensures your API behaves predictably when things go wrong, such as when a resource is not found or a database operation fails.
// tests/unit/postRouter.errors.test.ts
import { describe, it, expect, vi } from 'vitest';
import { postRouter } from '../../src/server/routers/post';
describe('postRouter.getById error handling', () => {
it('should throw NOT_FOUND when post does not exist', async () => {
const ctx = {
user: null,
db: {
post: {
findUnique: vi.fn().mockResolvedValue(null),
},
},
};
const caller = postRouter.createCaller(ctx);
await expect(caller.post.getById({ id: 'nonexistent' })).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
it('should return post when it exists', async () => {
const mockPost = { id: '1', title: 'Found', content: 'Content' };
const ctx = {
user: null,
db: {
post: {
findUnique: vi.fn().mockResolvedValue(mockPost),
},
},
};
const caller = postRouter.createCaller(ctx);
const result = await caller.post.getById({ id: '1' });
expect(result).toEqual(mockPost);
expect(ctx.db.post.findUnique).toHaveBeenCalledWith({
where: { id: '1' },
});
});
});
Integration Testing with Real Dependencies
While unit tests with mocks are fast and focused, integration tests verify that your procedures work correctly with real dependencies. For database interactions, you might use a test database or an in-memory database. This level of testing catches issues that mocks might hide, such as incorrect Prisma queries or schema mismatches.
// tests/integration/postRouter.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { postRouter } from '../../src/server/routers/post';
const prisma = new PrismaClient({
datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});
beforeAll(async () => {
await prisma.$connect();
});
afterAll(async () => {
await prisma.$disconnect();
});
beforeEach(async () => {
await prisma.post.deleteMany();
});
describe('postRouter integration', () => {
it('should create and retrieve a post', async () => {
const ctx = {
user: { id: 'admin-1', role: 'admin' as const },
db: prisma,
};
const caller = postRouter.createCaller(ctx);
const created = await caller.post.create({
title: 'Integration Test Post',
content: 'This is a real database test.',
});
const retrieved = await caller.post.getById({ id: created.id });
expect(retrieved.title).toBe('Integration Test Post');
expect(retrieved.content).toBe('This is a real database test.');
});
it('should list created posts', async () => {
const ctx = {
user: { id: 'admin-1', role: 'admin' as const },
db: prisma,
};
const caller = postRouter.createCaller(ctx);
await caller.post.create({ title: 'Post 1', content: 'Content 1' });
await caller.post.create({ title: 'Post 2', content: 'Content 2' });
const posts = await caller.post.list({ limit: 10 });
expect(posts).toHaveLength(2);
expect(posts.map(p => p.title)).toContain('Post 1');
expect(posts.map(p => p.title)).toContain('Post 2');
});
});
Testing the tRPC Server with HTTP Requests
Sometimes you need to test the full HTTP layer, including how tRPC handles requests over the wire. This is especially important if you have custom error formatting, CORS configuration, or request middleware. You can use supertest to make HTTP requests against your tRPC server.
// src/server/server.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { appRouter } from './root';
export function createServer() {
return createHTTPServer({
router: appRouter,
createContext: ({ req }) => {
const token = req.headers.authorization;
// In a real app, decode the token and return the user
return {
user: token ? { id: 'user-from-token', role: 'admin' as const } : null,
db: getDbClient(),
};
},
});
}
// tests/http/postRouter.http.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer } from '../../src/server/server';
import type { Server } from 'http';
let server: Server;
let port: number;
beforeAll(async () => {
server = createServer();
await new Promise<void>((resolve) => {
server.listen(0, () => {
const address = server.address();
if (address && typeof address === 'object') {
port = address.port;
}
resolve();
});
});
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe('POST router via HTTP', () => {
it('should return BAD_REQUEST for invalid input', async () => {
const response = await fetch(
`http://localhost:${port}/post.list`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ json: { limit: -1 } }),
}
);
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error.json.code).toBe('BAD_REQUEST');
});
it('should return UNAUTHORIZED for protected route without token', async () => {
const response = await fetch(
`http://localhost:${port}/post.create`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
json: { title: 'Test', content: 'Content' },
}),
}
);
const body = await response.json();
expect(body.error.json.code).toBe('UNAUTHORIZED');
});
});
End-to-End Testing with the tRPC Client
End-to-end tests verify the entire application flow, from the tRPC client through the server and down to the database. These tests are the most comprehensive but also the slowest. They are invaluable for catching integration issues that unit and integration tests might miss.
For E2E tests, you typically start the full application server and use the actual tRPC client to make requests. Here is an example using @trpc/client:
// tests/e2e/blog.e2e.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../src/server/root';
import { createServer } from '../../src/server/server';
import type { Server } from 'http';
let server: Server;
let port: number;
let client: ReturnType<typeof createTRPCProxyClient<AppRouter>>;
beforeAll(async () => {
server = createServer();
await new Promise<void>((resolve) => {
server.listen(0, () => {
const address = server.address();
if (address && typeof address === 'object') {
port = address.port;
}
resolve();
});
});
client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: `http://localhost:${port}`,
headers: () => ({
authorization: 'Bearer valid-admin-token',
}),
}),
],
});
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe('Blog E2E flow', () => {
it('should create a post, list it, and retrieve it by ID', async () => {
// Create a post
const created = await client.post.create.mutate({
title: 'E2E Test Post',
content: 'Created during E2E testing.',
});
expect(created.id).toBeDefined();
expect(created.title).toBe('E2E Test Post');
// List posts and verify the created post appears
const posts = await client.post.list.query({ limit: 10 });
expect(posts.some(p => p.id === created.id)).toBe(true);
// Retrieve the post by ID
const retrieved = await client.post.getById.query({ id: created.id });
expect(retrieved.title).toBe('E2E Test Post');
expect(retrieved.content).toBe('Created during E2E testing.');
});
it('should handle not-found errors gracefully', async () => {
await expect(
client.post.getById.query({ id: 'does-not-exist' })
).rejects.toMatchObject({
data: { code: 'NOT_FOUND' },
});
});
});
Testing with Playwright for Full Browser E2E
If your tRPC backend is consumed by a frontend application, you may want to test the entire user journey through a browser. Playwright is an excellent tool for this. The key is to start your application server before running Playwright tests and shut it down afterward.
// tests/e2e/playwright.setup.ts
import { createServer } from '../../src/server/server';
let server: ReturnType<typeof createServer>;
async function globalSetup() {
server = createServer();
await new Promise<void>((resolve) => {
server.listen(3001, () => resolve());
});
process.env.API_URL = 'http://localhost:3001';
}
async function globalTeardown() {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
export default globalSetup;
export { globalTeardown };
// tests/e2e/blog.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Blog application', () => {
test('user can view posts on the homepage', async ({ page }) => {
await page.goto('http://localhost:3000');
// Wait for posts to load
await page.waitForSelector('[data-testid="post-card"]');
const postCards = await page.locator('[data-testid="post-card"]').count();
expect(postCards).toBeGreaterThan(0);
});
test('admin can create a new post', async ({ page }) => {
// Log in as admin
await page.goto('http://localhost:3000/login');
await page.fill('[data-testid="email"]', 'admin@example.com');
await page.fill('[data-testid="password"]', 'password');
await page.click('[data-testid="login-button"]');
// Navigate to create post page
await page.goto('http://localhost:3000/posts/new');
await page.fill('[data-testid="post-title"]', 'Playwright Test Post');
await page.fill('[data-testid="post-content"]', 'Created via Playwright.');
await page.click('[data-testid="submit-post"]');
// Verify the post appears
await expect(page.locator('text=Playwright Test Post')).toBeVisible();
});
});
Best Practices for Testing tRPC Components
To get the most out of your tRPC testing strategy, follow these best practices:
- Test at the right level: Use unit tests for individual procedure logic, integration tests for database interactions, and E2E tests for critical user flows. Avoid over-testing at any single level.
- Mock at the boundaries: Mock the database and external services in unit tests, but use real dependencies in integration and E2E tests. This balance gives you both speed and confidence.
- Test both success and failure paths: Always test what happens when things go wrong — invalid input, missing resources, unauthorized access, and database errors.
- Use a consistent mock context factory: Create a helper function that produces a mock context with sensible defaults, and override only the fields relevant to each test. This reduces duplication and makes tests easier to read.
- Assert on error codes, not messages: tRPC error codes are stable and part of your API contract. Error messages may change, so assert on codes like
NOT_FOUNDandUNAUTHORIZEDinstead. - Keep tests independent: Each test should set up and tear down its own state. Avoid relying on test execution order, especially in integration and E2E tests.
- Test your Zod schemas separately: If you reuse Zod schemas across procedures, consider extracting them and testing them in isolation. This keeps your procedure tests focused on business logic.
- Use snapshot testing sparingly: While snapshots can be useful for complex response shapes, they can also become a maintenance burden. Use them for stable, rarely-changing outputs only.
- Run tests in CI: Integrate your test suite into your CI pipeline. Run unit tests on every commit and E2E tests on pull requests to catch regressions early.
- Measure coverage but do not chase it: Use coverage reports to identify untested code paths, but prioritize testing critical business logic over achieving 100% coverage.
Common Pitfalls to Avoid
When testing tRPC applications, developers often encounter a few common pitfalls. Being aware of these can save you significant debugging time:
- Forgetting to mock the full context: If your procedure accesses
ctx.db.postbut your mock only providesctx.user, the test will fail with a confusing error. Always ensure your mock context matches the shape your procedures expect. - Not testing middleware in isolation: Middleware like authentication checks should be tested independently of the procedures that use them. This ensures your security logic is correct regardless of which procedure invokes it.
- Over-mocking in integration tests: If you mock everything, you are writing unit tests, not integration tests. Integration tests should use real database connections to catch query-level bugs.
- Ignoring async error handling: tRPC procedures are async, and errors thrown in async code need to be tested with
rejectsassertions, notthrows.
Conclusion
Testing tRPC components effectively requires understanding the different layers of your application and choosing the right testing strategy for each. Unit tests with createCaller and mock contexts provide fast, focused validation of individual procedure logic. Integration tests with real databases catch issues that mocks hide. HTTP-level tests verify your server configuration and error handling. Finally, E2E tests with the tRPC client or Playwright ensure the entire system works together as expected. By combining these approaches and following best practices, you can build a robust test suite that gives you confidence in your tRPC-powered application, catching bugs before they reach your users and enabling you to iterate quickly without fear of regressions.