ProductReadyProductReady

测试指南

使用 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 助手很容易测试。

On this page