← Back to DevBytes

Testing tRPC Components: From Unit to E2E Tests

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:

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:

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles