测试指南
使用 Vitest 编写测试 - 单元测试、集成测试和 ProductReady 最佳实践
测试指南
ProductReady 使用 Vitest 进行快速、现代化的测试。学习如何编写单元测试、集成测试和测试 tRPC 端点。
为什么选择 Vitest? 闪电般快速的测试执行、ESM 优先、开箱即用的 TypeScript 支持,兼容 Jest API。
快速概览
测试技术栈:
- ✅ Vitest - 测试运行器和框架
- ✅ Testing Library - React 组件测试
- ✅ Playwright - E2E 测试(可选)
测试内容:
- ✅ tRPC 路由器(业务逻辑)
- ✅ 数据库查询
- ✅ React 组件
- ✅ 工具函数
- ✅ API 集成
运行测试
# 运行所有测试
pnpm test
# 监视模式(文件更改时重新运行)
pnpm test:watch
# 运行特定测试文件
pnpm test src/server/routers/posts.test.ts
# 覆盖率报告
pnpm test:coverage
# UI 模式(可视化测试运行器)
pnpm test:ui基本测试结构
基础测试
// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate } from './utils';
describe('formatDate', () => {
it('正确格式化日期', () => {
const date = new Date('2025-01-15');
expect(formatDate(date)).toBe('Jan 15, 2025');
});
it('处理无效日期', () => {
expect(formatDate(new Date('invalid'))).toBe('Invalid Date');
});
});测试 tRPC 路由器
设置测试调用器
// src/test/helpers.ts
import { appRouter } from '~/server/routers/_app';
import { createInnerTRPCContext } from '~/server/trpc';
import type { User } from '~/db/schema';
export function createTestCaller(user?: User) {
const ctx = createInnerTRPCContext({
session: user ? { user } : null,
});
return appRouter.createCaller(ctx);
}测试示例
// src/server/routers/posts.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createTestCaller } from '~/test/helpers';
import { db } from '~/db';
import { posts, users } from '~/db/schema';
describe('posts router', () => {
let testUser: User;
let caller: ReturnType<typeof createTestCaller>;
beforeEach(async () => {
// 创建测试用户
[testUser] = await db.insert(users).values({
email: 'test@example.com',
name: '测试用户',
}).returning();
caller = createTestCaller(testUser);
});
afterEach(async () => {
// 清理
await db.delete(posts);
await db.delete(users);
});
describe('create', () => {
it('创建文章', async () => {
const post = await caller.posts.create({
title: '测试文章',
content: '测试内容',
});
expect(post.title).toBe('测试文章');
expect(post.authorId).toBe(testUser.id);
});
it('需要身份验证', async () => {
const unauthCaller = createTestCaller(); // 无用户
await expect(
unauthCaller.posts.create({
title: '测试',
content: '测试',
})
).rejects.toThrow('UNAUTHORIZED');
});
});
describe('list', () => {
it('返回文章列表', async () => {
// 创建测试文章
await db.insert(posts).values([
{ title: '文章 1', content: '内容 1', authorId: testUser.id },
{ title: '文章 2', content: '内容 2', authorId: testUser.id },
]);
const result = await caller.posts.list();
expect(result).toHaveLength(2);
expect(result[0].title).toBe('文章 1');
});
});
});测试 React 组件
基本组件测试
// src/components/button.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Button } from './button';
describe('Button', () => {
it('渲染子元素', () => {
render(<Button>点击我</Button>);
expect(screen.getByText('点击我')).toBeInTheDocument();
});
it('应用变体类', () => {
render(<Button variant="destructive">删除</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('destructive');
});
it('处理点击事件', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>点击</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});覆盖率
生成报告
pnpm test:coverage在浏览器中查看报告:
- 打开
coverage/index.html
最佳实践
✅ 应该做的
- 测试行为,而非实现 - 关注用户所见
- 使用描述性测试名称 - "it should..." 格式
- 测试边缘情况 - 空数组、null 值、错误
- 保持测试隔离 - 每个测试应该独立
- 测试后清理 - 删除测试数据
- 模拟外部依赖 - API、邮件、支付
- 测试错误路径 - 不仅仅是成功路径
❌ 不应该做的
- 不要测试库代码 - 相信 React、tRPC 能正常工作
- 不要过度模拟 - 尽可能测试真实集成
- 不要跳过清理 - 会导致测试不稳定
- 不要测试实现细节 - 内部状态、私有方法
下一步
- 编写第一个测试 - 从工具函数开始
- 测试 tRPC 路由器 - 覆盖业务逻辑
- 添加到 CI/CD - 每次提交时运行测试
- 跟踪覆盖率 - 目标 80%+ 覆盖率
从测试 tRPC 路由器开始 - 它们包含您的业务逻辑,使用 createTestCaller 助手很容易测试。