0Pricing
tRPC End-to-End Type Safe APIs · Lesson

Unit Testing tRPC Procedures

Learn to write isolated unit tests for individual tRPC procedures using popular testing frameworks.

Unit Testing tRPC Procedures is a free tRPC End-to-End Type Safe APIs lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the tRPC End-to-End Type Safe APIs learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Unit Testing tRPC Procedures

Welcome! In this lesson, we'll dive into unit testing for tRPC. Unit testing focuses on verifying individual, isolated parts of your code, ensuring they work as expected.

For tRPC, a "unit" often refers to a single procedure (query or mutation). We'll learn how to test these procedures independently, without needing a full server running.

Benefits of Procedure Testing

Unit testing tRPC procedures offers several key advantages:

  • Reliability: Catch bugs early in development before they reach production.
  • Speed: Tests run quickly, providing instant feedback.
  • Isolation: Pinpoint issues to specific procedures, not complex integrations.
  • Type Safety: Ensures your procedure's inputs and outputs match your TypeScript definitions.

Essential Tools for tRPC Tests

While you can use various tools, popular choices for TypeScript projects include Jest or Vitest for the test runner. For tRPC backend unit tests, we primarily focus on TypeScript and a test runner's assertion capabilities.

We'll simulate how to call a procedure's internal handler directly for testing, which is the core concept regardless of your chosen test runner.

Deconstructing a Procedure Test

To unit test a tRPC procedure, you need to:

  1. Import the procedure.
  2. Create a mock context (ctx).
  3. Call the procedure's internal handler directly (e.g., _def.query or _def.mutation).
  4. Assert the returned value or any side effects.

Let's see a basic example:

// test-hello.ts
import { initTRPC } from '@trpc/server';

// 1. Define a simple tRPC procedure (or import from your app)
const t = initTRPC.create();
const helloProcedure = t.procedure
  .query(() => 'Hello tRPC');

// 2. Create a mock context (empty for this simple case)
const mockContext = {}; 

async function runHelloTest() {
  console.log("--- Testing 'helloProcedure' ---");

  // 3. Call the procedure's internal query handler
  const result = await helloProcedure._def.query({
    ctx: mockContext,
    input: undefined, // No input required
    path: 'hello',    // The procedure's path
    type: 'query'     // It's a query procedure
  });

  // 4. Assert the result
  console.log(`Expected: 'Hello tRPC'`);
  console.log(`Actual:   '${result}'`);

  if (result === 'Hello tRPC') {
    console.log("Test Passed: The procedure returned the correct message.");
  } else {
    console.log("Test Failed: Unexpected return value.");
  }
}

runHelloTest();

Isolating with Mock Context

The ctx object in tRPC procedures often contains important dependencies like user authentication status, database connections, or other services. To keep unit tests isolated, you must mock this context.

A mock context provides fake versions of these dependencies, allowing your procedure to run without interacting with real external systems.

// test-mock-context.ts
// Simulate a mock context for a procedure needing a user and database.

interface MockUser {
  id: string;
  name: string;
}

interface MockDB {
  findUserById: (id: string) => Promise<MockUser | null>;
  // ... other mock database methods
}

// Our mock database implementation
const mockDB: MockDB = {
  findUserById: async (id: string) => {
    console.log(`Mock DB: Finding user with ID: ${id}`);
    if (id === 'user123') {
      return { id: 'user123', name: 'Alice' };
    }
    return null;
  },
};

// Our mock tRPC context factory
const createMockContext = (user?: MockUser) => ({
  user: user || null,
  db: mockDB,
  // Add any other mocked services here
});

async function demonstrateMockContext() {
  console.log("--- Demonstrating Mock Context ---");
  
  const ctxWithUser = createMockContext({ id: 'user123', name: 'Alice' });
  console.log("Context with user: ", JSON.stringify(ctxWithUser.user));
  
  const ctxWithoutUser = createMockContext();
  console.log("Context without user: ", JSON.stringify(ctxWithoutUser.user));

  // You can even test mock DB calls
  const foundUser = await ctxWithUser.db.findUserById('user123');
  console.log("Found user via mock DB: ", JSON.stringify(foundUser));
}

demonstrateMockContext();

Example: Querying User Profile

Let's combine what we've learned to test a query that fetches a user's profile. This procedure will depend on the ctx for user information and a mock database.

We'll create a mock context that simulates an authenticated user and assert the correct profile is returned.

// test-user-query.ts
import { initTRPC } from '@trpc/server';

// Define our mock DB and User types
interface MockUser { id: string; name: string; email: string; }
interface MockDB { getUserById: (id: string) => Promise<MockUser | null>; }

// Mock DB implementation
const mockDB: MockDB = {
  getUserById: async (id: string) => {
    if (id === 'alice123') {
      return { id: 'alice123', name: 'Alice', email: 'alice@example.com' };
    }
    return null;
  },
};

// Create a mock context for our tests
const createMockContext = (userId?: string) => ({
  user: userId ? { id: userId, name: 'TestUser' } : null, // Simplified user obj
  db: mockDB,
});

// Our tRPC setup
const t = initTRPC.create();

// The procedure we want to test
const getUserProfile = t.procedure
  .query(async ({ ctx }) => {
    if (!ctx.user) {
      throw new Error('Unauthorized');
    }
    const user = await ctx.db.getUserById(ctx.user.id);
    if (!user) {
      throw new Error('User not found');
    }
    return { id: user.id, name: user.name, email: user.email };
  });

async function runUserProfileTest() {
  console.log("--- Testing 'getUserProfile' ---");

  // Test Case 1: Authorized user
  const authCtx = createMockContext('alice123');
  try {
    const result = await getUserProfile._def.query({
      ctx: authCtx, input: undefined, path: 'getUserProfile', type: 'query'
    });
    console.log("Authorized User Test Result: ", JSON.stringify(result));
    if (result.id === 'alice123' && result.name === 'Alice') {
      console.log("Authorized User Test Passed!");
    } else {
      console.log("Authorized User Test Failed!");
    }
  } catch (error: any) {
    console.log("Authorized User Test Failed with error: ", error.message);
  }

  // Test Case 2: Unauthorized user
  const unauthCtx = createMockContext(undefined);
  try {
    await getUserProfile._def.query({
      ctx: unauthCtx, input: undefined, path: 'getUserProfile', type: 'query'
    });
    console.log("Unauthorized User Test Failed: Should have thrown an error.");
  } catch (error: any) {
    console.log("Unauthorized User Test Result (Expected Error): ", error.message);
    if (error.message === 'Unauthorized') {
      console.log("Unauthorized User Test Passed!");
    } else {
      console.log("Unauthorized User Test Failed: Unexpected error message.");
    }
  }
}

runUserProfileTest();

Validating Inputs with Zod

Many procedures accept input, often validated using Zod schemas. When unit testing, you need to provide valid and invalid inputs to ensure both the procedure's logic and its Zod validation work correctly.

The input parameter in the _def.query or _def.mutation handler is where you pass these test inputs.

// test-post-by-id.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

// Mock DB for posts
interface MockPost { id: string; title: string; content: string; }
interface MockDB { getPostById: (id: string) => Promise<MockPost | null>; }

const mockDB: MockDB = {
  getPostById: async (id: string) => {
    if (id === 'post1') {
      return { id: 'post1', title: 'First Post', content: 'Lorem ipsum...' };
    }
    return null;
  },
};

const t = initTRPC.create();
const mockContext = { db: mockDB }; // Simple context

// Procedure with Zod input validation
const getPostById = t.procedure
  .input(z.object({
    id: z.string().min(1, "ID cannot be empty") // Simplified validation for example
  }))
  .query(async ({ input, ctx }) => {
    const post = await ctx.db.getPostById(input.id);
    if (!post) {
      throw new Error('Post not found');
    }
    return post;
  });

async function runPostByIdTest() {
  console.log("--- Testing 'getPostById' ---");

  // Test Case 1: Valid input, post found
  try {
    const result = await getPostById._def.query({
      ctx: mockContext,
      input: { id: 'post1' }, // Valid input
      path: 'getPostById',
      type: 'query'
    });
    console.log("Valid Input (Found) Test Result: ", JSON.stringify(result));
    if (result.id === 'post1') {
      console.log("Valid Input (Found) Test Passed!");
    } else {
      console.log("Valid Input (Found) Test Failed!");
    }
  } catch (error: any) {
    console.log("Valid Input (Found) Test Failed with error: ", error.message);
  }

  // Test Case 2: Valid input, post not found
  try {
    await getPostById._def.query({
      ctx: mockContext,
      input: { id: 'post2' }, // Valid format, but not in mock DB
      path: 'getPostById',
      type: 'query'
    });
    console.log("Valid Input (Not Found) Test Failed: Should have thrown error.");
  } catch (error: any) {
    console.log("Valid Input (Not Found) Test Result (Expected Error): ", error.message);
    if (error.message === 'Post not found') {
      console.log("Valid Input (Not Found) Test Passed!");
    } else {
      console.log("Valid Input (Not Found) Test Failed: Unexpected error.");
    }
  }
}

runPostByIdTest();

Unit Testing Data Changes

Mutation procedures modify data. When testing them, you need to ensure they perform the correct actions and return the expected results. The key here is to mock any external systems (like a database) to verify that the mutation interacts with them as expected.

You'll often use mock functions (e.g., Jest mocks) to track calls to your mocked dependencies.

// test-create-user.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

// Mock DB for users
interface UserInput { name: string; email: string; }
interface MockDB { createUser: (data: UserInput) => Promise<{ id: string } & UserInput>; }

// --- Mocking 'jest.fn' for runnable code --- 
// In a real environment, Jest would provide this. This is a simplified stand-in.
declare const jest: any; // Declare jest to avoid TS error in this isolated snippet
if (typeof jest === 'undefined') {
  globalThis.jest = {
    fn: (impl?: Function) => {
      const mockFunc: any = (...args: any[]) => {
        mockFunc.calls.push(args);
        return impl ? impl(...args) : undefined;
      };
      mockFunc.calls = [];
      mockFunc.mockClear = () => { mockFunc.calls = []; };
      return mockFunc;
    }
  };
}
// --- End Mocking 'jest.fn' --- 

// Implement a mock database with a 'mock' function for createUser
const mockCreateUser = jest.fn(async (data: UserInput) => {
  // Simulate database ID generation
  const newId = `user_${Math.random().toString(36).substr(2, 9)}`;
  console.log(`Mock DB: Creating user with ID ${newId}`);
  return { id: newId, ...data };
});

const mockDB: MockDB = {
  createUser: mockCreateUser,
};

const t = initTRPC.create();
const mockContext = { db: mockDB };

// Our mutation procedure
const createUser = t.procedure
  .input(z.object({
    name: z.string().min(3, "Name too short"),
    email: z.string().email("Invalid email format")
  }))
  .mutation(async ({ input, ctx }) => {
    const newUser = await ctx.db.createUser(input);
    return { success: true, userId: newUser.id };
  });

async function runCreateUserTest() {
  console.log("--- Testing 'createUser' Mutation ---");

  // Reset mock before each test (conceptual)
  mockCreateUser.mockClear();

  // Test Case 1: Valid input
  const validInput = { name: 'Bob', email: 'bob@example.com' };
  try {
    const result = await createUser._def.mutation({
      ctx: mockContext,
      input: validInput,
      path: 'createUser',
      type: 'mutation'
    });
    console.log("Valid Input Test Result: ", JSON.stringify(result));
    
    // Assertions: check return value and if mock was called
    if (result.success && result.userId) {
      console.log("Valid Input Test Passed: Mutation returned success.");
    } else {
      console.log("Valid Input Test Failed: Unexpected return value.");
    }
    // In a real test, you'd assert mockCreateUser was called with validInput
    console.log(`Mock createUser called: ${mockCreateUser.mock.calls.length > 0 ? 'Yes' : 'No'}`);
    if (mockCreateUser.mock.calls.length > 0 && mockCreateUser.mock.calls[0][0].name === 'Bob') {
      console.log("Mock createUser was called with correct data.");
    }
  } catch (error: any) {
    console.log("Valid Input Test Failed with error: ", error.message);
  }

  // Test Case 2: Invalid input (email)
  mockCreateUser.mockClear(); // Clear for next test
  const invalidInput = { name: 'Charlie', email: 'invalid-email' };
  try {
    await createUser._def.mutation({
      ctx: mockContext,
      input: invalidInput,
      path: 'createUser',
      type: 'mutation'
    });
    console.log("Invalid Input Test Failed: Should have thrown Zod error.");
  } catch (error: any) {
    console.log("Invalid Input Test Result (Expected Zod Error): ", error.message);
    if (error.message.includes('Invalid email format')) {
      console.log("Invalid Input Test Passed: Zod validation caught the error.");
    } else {
      console.log("Invalid Input Test Failed: Unexpected error message.");
    }
    console.log(`Mock createUser called: ${mockCreateUser.mock.calls.length > 0 ? 'Yes' : 'No'}`);
    if (mockCreateUser.mock.calls.length === 0) {
      console.log("Mock createUser was NOT called (as expected due to Zod error).");
    }
  }
}

runCreateUserTest();

Tips for Effective tRPC Tests

To maximize the value of your unit tests:

  • Focus on Isolation: Mock all external dependencies (DB, API calls, auth).
  • Test Edge Cases: Include valid, invalid, and boundary inputs.
  • Descriptive Names: Use clear test names that explain what's being tested.
  • Small & Focused: Each test should ideally verify one specific piece of logic.
  • Assert Correctly: Check both return values and side effects (e.g., if a mock function was called).

Testing Your Knowledge

When unit testing a tRPC mutation procedure that interacts with a database, which of the following practices are crucial for maintaining test isolation and effectiveness?

Lesson Summary

Great job! You've learned the fundamentals of unit testing tRPC procedures.

  • We covered why unit testing is important for reliability and speed.
  • You saw how to call procedure handlers directly and mock the ctx object.
  • We practiced testing both query and mutation procedures, including those with Zod input validation.

Keep practicing these techniques to build robust and maintainable tRPC APIs!

Frequently asked questions

Is the “Unit Testing tRPC Procedures” lesson free?

Yes — the full text of “Unit Testing tRPC Procedures” is free to read here on the web, and the tRPC End-to-End Type Safe APIs course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the tRPC End-to-End Type Safe APIs course, upgrade to CoddyKit PRO.

What will I learn in “Unit Testing tRPC Procedures”?

Learn to write isolated unit tests for individual tRPC procedures using popular testing frameworks. You practise tRPC End-to-End Type Safe APIs with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start tRPC End-to-End Type Safe APIs?

No prior experience is required. tRPC End-to-End Type Safe APIs on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Unit Testing tRPC Procedures” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this tRPC End-to-End Type Safe APIs lesson?

Yes. Every tRPC End-to-End Type Safe APIs lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Unit Testing tRPC Procedures
  2. Integration Testing tRPC Routers
  3. End-to-End Testing Strategies
  4. Mocking Context and Dependencies in tRPC Tests
← Back to tRPC End-to-End Type Safe APIs