Chapter 09

测试策略

从 Caller 单元测试到 Playwright E2E,构建可靠的 tRPC 测试体系

9.1 测试架构概览

tRPC 应用的测试分为三个层次:

单元测试

通过 caller 直接调用 Procedure,无 HTTP 开销,速度最快。适合测试单个 Procedure 的业务逻辑。

🔗

集成测试

启动真实 HTTP 服务器,模拟完整请求流程。测试中间件、错误处理、序列化等。

🎭

E2E 测试

Playwright 驱动真实浏览器,测试完整用户流程(含 UI 交互、路由跳转等)。

9.2 Caller 单元测试(无 HTTP)

tRPC 的 createCallerFactory 允许直接调用 Procedure,完全绕过 HTTP 层。这是测试 tRPC 业务逻辑最快、最直接的方式。

// server/__tests__/post.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createCallerFactory } from '@trpc/server';
import { appRouter } from '../router';
import { createMockContext } from './helpers';

const createCaller = createCallerFactory(appRouter);

describe('postRouter', () => {
  describe('post.list', () => {
    it('应该返回文章列表', async () => {
      const ctx = createMockContext();
      const caller = createCaller(ctx);

      const result = await caller.post.list({ page: 1, pageSize: 10 });

      expect(result).toBeInstanceOf(Array);
      expect(result.length).toBeLessThanOrEqual(10);
    });
  });

  describe('post.create', () => {
    it('未登录时应该抛出 UNAUTHORIZED', async () => {
      const ctx = createMockContext({ user: null }); // 未登录
      const caller = createCaller(ctx);

      await expect(
        caller.post.create({ title: 'Test', content: 'Content here' })
      ).rejects.toMatchObject({
        code: 'UNAUTHORIZED',
      });
    });

    it('登录用户可以创建文章', async () => {
      const ctx = createMockContext({
        user: { id: 'user-1', email: 'test@test.com', role: 'user' }
      });
      const caller = createCaller(ctx);

      const post = await caller.post.create({
        title: '我的第一篇文章',
        content: '这是内容,超过十个字',
      });

      expect(post.title).toBe('我的第一篇文章');
      expect(post.authorId).toBe('user-1');
    });

    it('标题为空时应该抛出 BAD_REQUEST', async () => {
      const ctx = createMockContext({ user: { id: 'user-1' } });
      const caller = createCaller(ctx);

      await expect(
        caller.post.create({ title: '', content: '内容内容内容' })
      ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
    });
  });
});
TS

9.3 Mock Context 工厂

// server/__tests__/helpers.ts
import { vi } from 'vitest';
import type { Context } from '../context';

// Mock 数据库(使用内存存储或 prisma 测试客户端)
export const mockDb = {
  post: {
    findMany: vi.fn().mockResolvedValue([]),
    findUnique: vi.fn().mockResolvedValue(null),
    create: vi.fn().mockImplementation(({ data }) =>
      Promise.resolve({ id: 'post-1', ...data, createdAt: new Date() })
    ),
    delete: vi.fn().mockResolvedValue(null),
  },
  user: {
    findUnique: vi.fn().mockResolvedValue(null),
    create: vi.fn(),
  },
};

/**
 * 创建测试用 Context
 * @param overrides 要覆盖的 Context 字段
 */
export function createMockContext(
  overrides: Partial<Context> = {}
): Context {
  return {
    db: mockDb as any,
    session: overrides.user ? {
      user: overrides.user,
      expires: new Date(Date.now() + 86400000).toISOString(),
    } : null,
    ...overrides,
  };
}
TS

9.4 集成测试:真实 HTTP 服务器

// server/__tests__/integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { appRouter } from '../router';
import type { AppRouter } from '../router';

let server: ReturnType<typeof createHTTPServer>;
let client: ReturnType<typeof createTRPCClient<AppRouter>>;
let port: number;

beforeAll(async () => {
  server = createHTTPServer({
    router: appRouter,
    createContext: () => ({ db: mockDb }),
  });

  // 监听随机端口,避免端口冲突
  await new Promise<void>(resolve => server.listen(0, resolve));
  port = (server.server.address() as AddressInfo).port;

  client = createTRPCClient<AppRouter>({
    links: [httpBatchLink({ url: `http://localhost:${port}` })],
  });
});

afterAll(() => server.close());

describe('integration', () => {
  it('GET /post.list 应该成功', async () => {
    const result = await client.post.list.query({ page: 1 });
    expect(result).toBeInstanceOf(Array);
  });

  it('未认证请求应该返回 401', async () => {
    await expect(
      client.post.create.mutate({ title: 'Test', content: 'Content' })
    ).rejects.toThrow('UNAUTHORIZED');
  });
});
TS

9.5 类型测试:验证类型推断

tRPC 的核心价值是类型安全。使用 expectTypeOf(vitest 内置)验证类型推断是否符合预期:

// server/__tests__/types.test.ts
import { expectTypeOf, describe, it } from 'vitest';
import { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '../router';

type RouterOutput = inferRouterOutputs<AppRouter>;

describe('类型推断测试', () => {
  it('post.list 返回数组', () => {
    type PostList = RouterOutput['post']['list'];
    expectTypeOf<PostList>().toBeArray();
  });

  it('post 对象有 title 字段(string 类型)', () => {
    type Post = RouterOutput['post']['list'][0];
    expectTypeOf<Post['title']>().toBeString();
  });

  it('post 对象不应该有 passwordHash 字段', () => {
    type Post = RouterOutput['post']['list'][0];
    // 使用 @ts-expect-error 验证字段不存在
    // @ts-expect-error
    type NoPasswordHash = Post['passwordHash'];
  });
});
TS

9.6 测试数据库:Prisma + SQLite

在真实数据库测试中,推荐使用 SQLite 内存数据库进行隔离测试:

// vitest.config.ts
export default {
  test: {
    setupFiles: ['./tests/setup.ts'],
    globalSetup: ['./tests/global-setup.ts'],
  },
};

// tests/setup.ts — 每个测试文件前重置数据库
import { beforeEach, afterEach } from 'vitest';
import { PrismaClient } from '@prisma/client';

export const testDb = new PrismaClient({
  datasources: { db: { url: 'file::memory:' } },
});

beforeEach(async () => {
  // 每次测试前清空数据
  await testDb.post.deleteMany();
  await testDb.user.deleteMany();
});
TS

9.7 Playwright E2E 测试

// e2e/posts.spec.ts
import { test, expect } from '@playwright/test';

test.describe('文章管理', () => {
  test.beforeEach(async ({ page }) => {
    // 登录(通过测试 API 创建测试 Session)
    await page.goto('/api/auth/test-login?userId=test-user');
    await page.goto('/posts');
  });

  test('应该能创建文章', async ({ page }) => {
    await page.fill('[name="title"]', 'E2E 测试文章');
    await page.fill('[name="content"]', '这是一篇测试文章的内容');
    await page.click('button[type="submit"]');

    await expect(page.locator('text=E2E 测试文章')).toBeVisible();
  });

  test('未填标题时应该显示错误提示', async ({ page }) => {
    await page.click('button[type="submit"]');
    await expect(page.locator('text=标题不能为空')).toBeVisible();
  });

  test('应该能删除文章', async ({ page }) => {
    // 先创建,再删除
    const title = '待删除的文章';
    await page.fill('[name="title"]', title);
    await page.fill('[name="content"]', '内容内容内容内容');
    await page.click('button[type="submit"]');
    await expect(page.locator(`text=${title}`)).toBeVisible();

    await page.locator(`[aria-label="删除 ${title}"]`).click();
    await expect(page.locator(`text=${title}`)).not.toBeVisible();
  });
});
TS
💡

本章小结tRPC 测试的推荐策略:① 用 createCallerFactory 做单元测试(最快、隔离性好);② 用 createHTTPServer 做集成测试(验证 HTTP 层、中间件);③ 用 createMockContext 模拟各种用户状态(未登录、普通用户、管理员);④ 用 expectTypeOf 验证类型推断正确性;⑤ 用 Playwright 做 E2E 测试覆盖关键用户流程。