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 测试覆盖关键用户流程。