0Pricing
tRPC End-to-End Type Safe APIs · 课时

测试 tRPC 过程的单元

学习使用热门测试框架,为单个 tRPC 过程编写隔离的单元测试。

测试 tRPC 过程的单元 是 CoddyKit 上的免费 tRPC End-to-End Type Safe APIs 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 tRPC End-to-End Type Safe APIs 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 tRPC End-to-End Type Safe APIs 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

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!

常见问题解答

「测试 tRPC 过程的单元」课时是免费的吗?

是的 — 「测试 tRPC 过程的单元」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 tRPC End-to-End Type Safe APIs 课程的其余内容,请升级到 CoddyKit PRO。 tRPC End-to-End Type Safe APIs 课程共包含 4 节课。

「测试 tRPC 过程的单元」这节课中我会学到什么?

学习使用热门测试框架,为单个 tRPC 过程编写隔离的单元测试。 你通过在浏览器中直接运行的动手代码来练习 tRPC End-to-End Type Safe APIs,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 tRPC End-to-End Type Safe APIs 需要有经验吗?

无需任何先前经验。CoddyKit 上的 tRPC End-to-End Type Safe APIs 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。

「测试 tRPC 过程的单元」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 tRPC End-to-End Type Safe APIs 课中编写并运行代码吗?

能。每节 tRPC End-to-End Type Safe APIs 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 测试 tRPC 过程的单元
  2. 测试 tRPC 路由器的集成
  3. 端到端测试策略
  4. 在 tRPC 测试中模拟上下文与依赖
← 返回 tRPC End-to-End Type Safe APIs