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

测试 tRPC 路由器的集成

设置集成测试,验证不同 tRPC 过程及其依赖之间是否正确交互。

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

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

What are Integration Tests?

Welcome to integration testing for tRPC! While unit tests check individual functions, integration tests verify how different parts of your tRPC application work together.

This often involves testing the interaction between tRPC procedures and external dependencies, like a database or another service.

Unit vs. Integration Tests

Let's clarify the difference:

  • Unit Tests: Focus on isolating a single tRPC procedure. They mock all external dependencies to test the procedure's logic alone.
  • Integration Tests: Test the flow between multiple procedures or between a procedure and a real (or mocked) external dependency, like ensuring a mutation correctly updates a database.

Setting Up for Integration Tests

For integration tests, you need a controlled environment. This often means:

  • Using an in-memory database or a dedicated test database instance.
  • Mocking only necessary external services, not core dependencies like your database.
  • Setting up a tRPC test caller to invoke procedures directly, bypassing HTTP requests.

Creating a tRPC Test Caller

To call your tRPC procedures directly within tests, you create a test caller. This object lets you interact with your router without a full HTTP server, making tests faster and easier to set up.

Try running this example to see a basic caller in action:

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.context<any>().create();
const publicProcedure = t.procedure;

const appRouter = t.router({
  greeting: publicProcedure
    .input(z.object({ name: z.string() }).optional())
    .query(({ input }) => {
      return `Hello, ${input?.name || 'world'}!`;
    }),
  addNumber: publicProcedure
    .input(z.object({ num1: z.number(), num2: z.number() }))
    .mutation(({ input }) => {
      return input.num1 + input.num2;
    }),
});

const caller = appRouter.createCaller({});

async function runSimulatedTest() {
  console.log("Simulating tRPC integration test...");

  const greetingResult = await caller.greeting.query({ name: "Coddy" });
  console.log("Greeting result:", greetingResult);

  const sumResult = await caller.addNumber.mutation({ num1: 5, num2: 3 });
  console.log("Sum result:", sumResult);
}

runSimulatedTest();

Testing a Query Procedure

Integration tests for queries verify that data is fetched correctly, often involving a mock database. We'll ensure the query returns the expected data based on the current state.

Observe how the greeting query behaves with and without input:

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.context<any>().create();
const publicProcedure = t.procedure;

const appRouter = t.router({
  greeting: publicProcedure
    .input(z.object({ name: z.string() }).optional())
    .query(({ input }) => {
      // In a real test, this might fetch from a DB
      return `Hello, ${input?.name || 'world'}!`;
    }),
});

const caller = appRouter.createCaller({});

async function testGreetingQuery() {
  console.log("--- Testing 'greeting' query ---");

  // Test with a name
  const resultWithName = await caller.greeting.query({ name: "Alice" });
  console.log("Result with name:", resultWithName);

  // Test without a name (default behavior)
  const resultNoName = await caller.greeting.query();
  console.log("Result no name:", resultNoName);

  console.log("Query tests complete.");
}

testGreetingQuery();

Testing a Mutation Procedure

Mutations change data. An integration test for a mutation should verify that the data changes as expected and that subsequent queries reflect those changes. We'll simulate an in-memory database.

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.context<any>().create();
const publicProcedure = t.procedure;

// Simulate an in-memory database
const _db: { messages: string[] } = { messages: [] };

const appRouter = t.router({
  getMessages: publicProcedure
    .query(() => _db.messages),
  addMessage: publicProcedure
    .input(z.object({ text: z.string() }))
    .mutation(({ input }) => {
      _db.messages.push(input.text);
      return input.text;
    }),
});

const caller = appRouter.createCaller({});

async function testAddMessageMutation() {
  console.log("--- Testing 'addMessage' mutation ---");

  // 1. Check initial state
  let initialMessages = await caller.getMessages.query();
  console.log("Initial messages:", initialMessages);

  // 2. Perform mutation
  const addedText = await caller.addMessage.mutation({ text: "Hello tRPC!" });
  console.log("Added message:", addedText);

  // 3. Check state after mutation
  let updatedMessages = await caller.getMessages.query();
  console.log("Updated messages:", updatedMessages);

  // 4. Perform another mutation
  await caller.addMessage.mutation({ text: "More data!" });
  updatedMessages = await caller.getMessages.query();
  console.log("Further updated messages:", updatedMessages);

  console.log("Mutation tests complete.");
}

testAddMessageMutation();

Verifying Merged Routers

In larger tRPC applications, you'll organize your API into multiple routers and then merge them into a root router. Integration tests should confirm that procedures from different merged routers can be accessed and interact correctly.

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.context<any>().create();
const publicProcedure = t.procedure;

// Simulate an in-memory database
const _db: { users: string[]; products: string[] } = { users: [], products: [] };

// User router
const userRouter = t.router({
  getUsers: publicProcedure.query(() => _db.users),
  addUser: publicProcedure
    .input(z.object({ name: z.string() }))
    .mutation(({ input }) => {
      _db.users.push(input.name);
      return input.name;
    }),
});

// Product router
const productRouter = t.router({
  getProducts: publicProcedure.query(() => _db.products),
  addProduct: publicProcedure
    .input(z.object({ name: z.string() }))
    .mutation(({ input }) => {
      _db.products.push(input.name);
      return input.name;
    }),
});

// Root router merging user and product routers
const appRouter = t.router({
  user: userRouter,
  product: productRouter,
});

const caller = appRouter.createCaller({});

async function testMergedRouters() {
  console.log("--- Testing merged routers ---");

  // Add a user
  await caller.user.addUser.mutation({ name: "Bob" });
  const users = await caller.user.getUsers.query();
  console.log("Users after adding Bob:", users);

  // Add a product
  await caller.product.addProduct.mutation({ name: "Laptop" });
  const products = await caller.product.getProducts.query();
  console.log("Products after adding Laptop:", products);

  console.log("All users:", await caller.user.getUsers.query());
  console.log("All products:", await caller.product.getProducts.query());

  console.log("Merged router tests complete.");
}

testMergedRouters();

Testing with Context Data

Many tRPC procedures rely on the context object, which might contain data like an authenticated user's ID or roles. In integration tests, you'll provide a mock context when creating your caller to simulate different user states.

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

// Define the shape of your context
interface MyContext {
  userId?: string;
  isAdmin?: boolean;
}

const t = initTRPC.context<MyContext>().create();
const publicProcedure = t.procedure;

const protectedProcedure = publicProcedure.use(
  t.middleware(({ ctx, next }) => {
    if (!ctx.userId) {
      throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
    }
    return next({ ctx: { ...ctx, userId: ctx.userId } });
  })
);

const adminProcedure = protectedProcedure.use(
  t.middleware(({ ctx, next }) => {
    if (!ctx.isAdmin) {
      throw new TRPCError({ code: 'FORBIDDEN', message: 'Not an admin' });
    }
    return next({ ctx: { ...ctx, isAdmin: ctx.isAdmin } });
  })
);

const appRouter = t.router({
  whoAmI: protectedProcedure.query(({ ctx }) => `You are user ${ctx.userId}`),
  adminAction: adminProcedure.mutation(() => "Admin action successful!"),
});

async function testWithMockContext() {
  console.log("--- Testing with mock context ---");

  // 1. Test unauthenticated user
  const unauthCaller = appRouter.createCaller({});
  try {
    await unauthCaller.whoAmI.query();
  } catch (error: any) {
    console.log("Unauthenticated user error:", error.message);
  }

  // 2. Test authenticated user
  const authCaller = appRouter.createCaller({ userId: "user123" });
  const userResult = await authCaller.whoAmI.query();
  console.log("Authenticated user result:", userResult);

  // 3. Test non-admin user trying admin action
  try {
    await authCaller.adminAction.mutation();
  } catch (error: any) {
    console.log("Non-admin user error:", error.message);
  }

  // 4. Test admin user
  const adminCaller = appRouter.createCaller({ userId: "admin456", isAdmin: true });
  const adminResult = await adminCaller.adminAction.mutation();
  console.log("Admin user result:", adminResult);

  console.log("Context tests complete.");
}

testWithMockContext();

Cleaning Up Test Data

Integration tests that modify shared state (like a database) can create dependencies between tests if not managed carefully. It's crucial to clean up any created data after each test or test suite.

  • Use beforeEach/afterEach hooks in your test runner.
  • Reset mock databases or truncate tables to ensure tests are isolated and repeatable.

Integration Test Challenge

Consider a tRPC setup where a userRouter handles user creation and a postRouter handles post creation, with posts requiring a valid userId. You are writing an integration test.

Recap: Integration Testing tRPC

We've explored how to perform integration tests for tRPC routers. You learned to:

  • Use a test caller to invoke procedures directly.
  • Test both query and mutation procedures, observing state changes.
  • Verify interactions across merged routers.
  • Provide mock context for authenticated tests.
  • Understand the importance of test data cleanup.

Integration tests are vital for ensuring your tRPC API's components work harmoniously and correctly interact with dependencies.

常见问题解答

「测试 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 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 2 节课,共 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