mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
* 🔖 chore(release): release version v2.1.34 [skip ci] * 📝 docs: Polish documents * 📝 docs: Fix typo * 📝 docs: Update start * 📝 docs: Fix style * 📝 docs: Update start * 📝 docs: Update layout * 📝 docs: Fix typo * 📝 docs: Fix typo --------- Co-authored-by: lobehubbot <i@lobehub.com>
558 lines
12 KiB
Text
558 lines
12 KiB
Text
---
|
||
title: 测试指南
|
||
description: 了解 LobeHub 的测试策略,包括使用 Vitest 进行单元测试以及使用 Playwright + Cucumber 进行端到端测试。
|
||
tags:
|
||
- LobeHub
|
||
- 单元测试
|
||
- 端到端测试
|
||
- 测试策略
|
||
- vitest
|
||
- Playwright
|
||
---
|
||
|
||
# 测试指南
|
||
|
||
LobeHub 的测试策略包括使用 [Vitest][vitest-url] 进行单元测试,以及使用 Playwright + Cucumber 进行端到端 (E2E) 测试。本指南介绍如何高效地编写和运行测试。
|
||
|
||
## 概述
|
||
|
||
我们的测试策略包括:
|
||
|
||
- **单元测试** — 使用 Vitest 测试函数、组件和状态存储
|
||
- **E2E 测试** — 使用 Playwright + Cucumber 测试用户流程
|
||
- **类型检查** — TypeScript 编译器(`bun run type-check`)
|
||
- **代码规范** — ESLint、Stylelint
|
||
|
||
## 快速参考
|
||
|
||
### 命令
|
||
|
||
```bash
|
||
# 运行指定的单元测试(推荐)
|
||
bunx vitest run --silent='passed-only' 'path/to/test.test.ts'
|
||
|
||
# 在包中运行测试(例如 database 包)
|
||
cd packages/database && bunx vitest run --silent='passed-only' 'src/models/user.test.ts'
|
||
|
||
# 类型检查
|
||
bun run type-check
|
||
|
||
# E2E 测试
|
||
pnpm e2e
|
||
```
|
||
|
||
<Callout type={'warning'}>
|
||
**切勿运行完整测试套件**(`bun run test`)—— 这会运行所有测试,耗时约 10 分钟。请始终使用
|
||
`bunx vitest run --silent='passed-only' '[file-path]'` 指定目标文件。
|
||
</Callout>
|
||
|
||
## 使用 Vitest 进行单元测试
|
||
|
||
### 测试文件结构
|
||
|
||
测试文件与被测代码并列放置,命名为 `<filename>.test.ts`:
|
||
|
||
```
|
||
src/utils/
|
||
├── formatDate.ts
|
||
└── formatDate.test.ts
|
||
```
|
||
|
||
### 编写测试用例
|
||
|
||
使用 `describe` 和 `it` 组织测试用例,使用 `beforeEach`/`afterEach` 管理测试前后的状态:
|
||
|
||
```typescript
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
import { formatDate } from './formatDate';
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('formatDate', () => {
|
||
describe('使用默认格式', () => {
|
||
it('应正确格式化日期', () => {
|
||
const date = new Date('2024-03-15');
|
||
const result = formatDate(date);
|
||
expect(result).toBe('Mar 15, 2024');
|
||
});
|
||
});
|
||
|
||
describe('使用自定义格式', () => {
|
||
it('应使用自定义格式', () => {
|
||
const date = new Date('2024-03-15');
|
||
const result = formatDate(date, 'YYYY-MM-DD');
|
||
expect(result).toBe('2024-03-15');
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 测试 React 组件
|
||
|
||
使用 `@testing-library/react` 测试组件行为:
|
||
|
||
```typescript
|
||
import { describe, it, expect, vi } from 'vitest';
|
||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||
import { UserProfile } from './UserProfile';
|
||
|
||
describe('UserProfile', () => {
|
||
it('应渲染用户名', () => {
|
||
render(<UserProfile name="Alice" />);
|
||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||
});
|
||
|
||
it('点击按钮时应调用 onClick', () => {
|
||
const onClick = vi.fn();
|
||
render(<UserProfile name="Alice" onClick={onClick} />);
|
||
|
||
fireEvent.click(screen.getByRole('button'));
|
||
expect(onClick).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('应处理异步数据加载', async () => {
|
||
render(<UserProfile userId="123" />);
|
||
|
||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 测试 Zustand 状态存储
|
||
|
||
在 `beforeEach` 中重置 store 状态,确保测试互相独立:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { act } from '@testing-library/react';
|
||
import { useUserStore } from './index';
|
||
|
||
beforeEach(() => {
|
||
useUserStore.setState({
|
||
users: {},
|
||
currentUserId: null,
|
||
});
|
||
});
|
||
|
||
describe('useUserStore', () => {
|
||
describe('addUser', () => {
|
||
it('应将用户添加到 store', () => {
|
||
const user = { id: '1', name: 'Alice' };
|
||
|
||
act(() => {
|
||
useUserStore.getState().addUser(user);
|
||
});
|
||
|
||
const state = useUserStore.getState();
|
||
expect(state.users['1']).toEqual(user);
|
||
});
|
||
});
|
||
|
||
describe('setCurrentUser', () => {
|
||
it('应更新当前用户 ID', () => {
|
||
act(() => {
|
||
useUserStore.getState().setCurrentUser('123');
|
||
});
|
||
|
||
expect(useUserStore.getState().currentUserId).toBe('123');
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### Mock(模拟)
|
||
|
||
#### 优先使用 `vi.spyOn` 而非 `vi.mock`
|
||
|
||
```typescript
|
||
// ✅ 推荐 — spyOn 作用域明确,且会自动恢复
|
||
vi.spyOn(messageService, 'createMessage').mockResolvedValue('msg_123');
|
||
|
||
// ❌ 避免 — 全局 mock 容易在测试间产生污染
|
||
vi.mock('@/services/message');
|
||
```
|
||
|
||
#### Mock 浏览器 API
|
||
|
||
```typescript
|
||
// Mock Image
|
||
const mockImage = vi.fn(() => ({
|
||
addEventListener: vi.fn((event, handler) => {
|
||
if (event === 'load') setTimeout(handler, 0);
|
||
}),
|
||
removeEventListener: vi.fn(),
|
||
}));
|
||
vi.stubGlobal('Image', mockImage);
|
||
|
||
// Mock URL.createObjectURL
|
||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
|
||
|
||
// Mock fetch
|
||
global.fetch = vi.fn(() =>
|
||
Promise.resolve({
|
||
json: () => Promise.resolve({ data: 'test' }),
|
||
ok: true,
|
||
}),
|
||
);
|
||
```
|
||
|
||
#### Mock 模块
|
||
|
||
```typescript
|
||
// Mock 外部库
|
||
vi.mock('axios', () => ({
|
||
default: {
|
||
get: vi.fn(() => Promise.resolve({ data: {} })),
|
||
},
|
||
}));
|
||
|
||
// Mock 内部模块
|
||
vi.mock('@/utils/logger', () => ({
|
||
logger: {
|
||
info: vi.fn(),
|
||
error: vi.fn(),
|
||
},
|
||
}));
|
||
```
|
||
|
||
### 测试异步代码
|
||
|
||
```typescript
|
||
import { waitFor } from '@testing-library/react';
|
||
|
||
it('应异步加载数据', async () => {
|
||
await expect(fetchUser('123')).resolves.toEqual({ id: '123', name: 'Alice' });
|
||
});
|
||
|
||
it('应处理错误', async () => {
|
||
await expect(fetchUser('invalid')).rejects.toThrow('User not found');
|
||
});
|
||
```
|
||
|
||
### 测试数据库代码
|
||
|
||
针对包中的数据库和 ORM 测试:
|
||
|
||
```bash
|
||
# 客户端 DB 测试
|
||
cd packages/database
|
||
bunx vitest run --silent='passed-only' 'src/models/user.test.ts'
|
||
|
||
# 服务端 DB 测试
|
||
cd packages/database
|
||
TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' 'src/models/user.test.ts'
|
||
```
|
||
|
||
## 使用 Playwright 进行 E2E 测试
|
||
|
||
### 运行 E2E 测试
|
||
|
||
```bash
|
||
# 运行所有 E2E 测试
|
||
pnpm e2e
|
||
|
||
# 交互模式(UI 界面)
|
||
pnpm e2e:ui
|
||
|
||
# 仅运行冒烟测试(快速验证)
|
||
pnpm test:e2e:smoke
|
||
```
|
||
|
||
### E2E 测试结构
|
||
|
||
E2E 测试位于 `e2e/` 目录下:
|
||
|
||
```
|
||
e2e/
|
||
├── features/ # Cucumber feature 文件(.feature)
|
||
│ ├── auth.feature
|
||
│ └── chat.feature
|
||
├── step-definitions/ # 步骤实现
|
||
│ ├── auth.steps.ts
|
||
│ └── chat.steps.ts
|
||
├── support/ # 共享辅助函数和 hooks
|
||
└── playwright.config.ts
|
||
```
|
||
|
||
### 编写 E2E 测试
|
||
|
||
**Feature 文件** (`e2e/features/chat.feature`):
|
||
|
||
```gherkin
|
||
Feature: 聊天功能
|
||
|
||
Scenario: 用户发送消息
|
||
Given 我已登录
|
||
And 我在聊天页面
|
||
When 我输入 "你好,AI!"
|
||
And 我点击发送按钮
|
||
Then 我应该在聊天中看到我的消息
|
||
And 我应该看到 AI 的回复
|
||
```
|
||
|
||
**步骤定义** (`e2e/step-definitions/chat.steps.ts`):
|
||
|
||
```typescript
|
||
import { Given, When, Then } from '@cucumber/cucumber';
|
||
import { expect } from '@playwright/test';
|
||
|
||
Given('我在聊天页面', async function () {
|
||
await this.page.goto('/chat');
|
||
});
|
||
|
||
When('我输入 {string}', async function (message: string) {
|
||
await this.page.fill('[data-testid="chat-input"]', message);
|
||
});
|
||
|
||
When('我点击发送按钮', async function () {
|
||
await this.page.click('[data-testid="send-button"]');
|
||
});
|
||
|
||
Then('我应该在聊天中看到我的消息', async function () {
|
||
await expect(this.page.locator('.user-message').last()).toBeVisible();
|
||
});
|
||
```
|
||
|
||
## 最佳实践
|
||
|
||
### 1. 测试行为,而非实现细节
|
||
|
||
```typescript
|
||
// ✅ 推荐 — 测试用户可感知的行为
|
||
it('应允许用户提交表单', () => {
|
||
render(<ContactForm />);
|
||
|
||
fireEvent.change(screen.getByLabelText('Name'), {
|
||
target: { value: 'Alice' },
|
||
});
|
||
fireEvent.click(screen.getByText('Submit'));
|
||
|
||
expect(screen.getByText('Form submitted')).toBeInTheDocument();
|
||
});
|
||
|
||
// ❌ 避免 — 测试内部实现细节
|
||
it('输入变化时应调用 setState', () => {
|
||
const setState = vi.fn();
|
||
render(<ContactForm setState={setState} />);
|
||
|
||
fireEvent.change(screen.getByLabelText('Name'), {
|
||
target: { value: 'Alice' },
|
||
});
|
||
|
||
expect(setState).toHaveBeenCalled();
|
||
});
|
||
```
|
||
|
||
### 2. 使用语义化查询
|
||
|
||
```typescript
|
||
// ✅ 推荐 — 语义化查询与用户感知一致
|
||
screen.getByRole('button', { name: 'Submit' });
|
||
screen.getByLabelText('Email address');
|
||
screen.getByText('Welcome back');
|
||
|
||
// ❌ 避免 — testId 应作为最后手段
|
||
screen.getByTestId('submit-button');
|
||
```
|
||
|
||
### 3. 每次测试后清理状态
|
||
|
||
```typescript
|
||
import { beforeEach, afterEach, vi } from 'vitest';
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks(); // 清除 mock 调用历史
|
||
vi.clearAllTimers(); // 使用 fake timers 时清除计时器
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks(); // 恢复原始实现
|
||
});
|
||
```
|
||
|
||
### 4. 测试边界条件
|
||
|
||
```typescript
|
||
describe('validateEmail', () => {
|
||
it('应接受有效的电子邮件', () => {
|
||
expect(validateEmail('user@example.com')).toBe(true);
|
||
});
|
||
|
||
it('应拒绝空字符串', () => {
|
||
expect(validateEmail('')).toBe(false);
|
||
});
|
||
|
||
it('应拒绝不含 @ 的邮件', () => {
|
||
expect(validateEmail('user.example.com')).toBe(false);
|
||
});
|
||
|
||
it('应拒绝缺少域名的邮件', () => {
|
||
expect(validateEmail('user@')).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
### 5. 保持测试相互独立
|
||
|
||
```typescript
|
||
// ❌ 避免 — 测试之间共享状态,依赖执行顺序
|
||
let userId: string;
|
||
|
||
it('应创建用户', () => {
|
||
userId = createUser('Alice');
|
||
expect(userId).toBeDefined();
|
||
});
|
||
|
||
it('应获取用户', () => {
|
||
const user = getUser(userId); // 依赖上一个测试
|
||
expect(user.name).toBe('Alice');
|
||
});
|
||
|
||
// ✅ 推荐 — 每个测试自行准备数据
|
||
it('应创建用户', () => {
|
||
const userId = createUser('Alice');
|
||
expect(userId).toBeDefined();
|
||
});
|
||
|
||
it('应获取用户', () => {
|
||
const userId = createUser('Bob');
|
||
const user = getUser(userId);
|
||
expect(user.name).toBe('Bob');
|
||
});
|
||
```
|
||
|
||
## 常用模式
|
||
|
||
### 测试 Hooks
|
||
|
||
```typescript
|
||
import { renderHook, act } from '@testing-library/react';
|
||
import { useCounter } from './useCounter';
|
||
|
||
it('应递增计数器', () => {
|
||
const { result } = renderHook(() => useCounter());
|
||
|
||
expect(result.current.count).toBe(0);
|
||
|
||
act(() => {
|
||
result.current.increment();
|
||
});
|
||
|
||
expect(result.current.count).toBe(1);
|
||
});
|
||
```
|
||
|
||
### 测试 Context Provider
|
||
|
||
```typescript
|
||
import { render, screen } from '@testing-library/react';
|
||
import { ThemeProvider } from './ThemeProvider';
|
||
import { MyComponent } from './MyComponent';
|
||
|
||
function renderWithTheme(component: React.ReactElement) {
|
||
return render(<ThemeProvider theme="dark">{component}</ThemeProvider>);
|
||
}
|
||
|
||
it('应使用主题', () => {
|
||
renderWithTheme(<MyComponent />);
|
||
expect(screen.getByRole('main')).toHaveClass('dark-theme');
|
||
});
|
||
```
|
||
|
||
### 使用 MSW 测试 API 调用
|
||
|
||
```typescript
|
||
import { rest } from 'msw';
|
||
import { setupServer } from 'msw/node';
|
||
|
||
const server = setupServer(
|
||
rest.get('/api/user', (req, res, ctx) => {
|
||
return res(ctx.json({ id: '1', name: 'Alice' }));
|
||
}),
|
||
);
|
||
|
||
beforeAll(() => server.listen());
|
||
afterEach(() => server.resetHandlers());
|
||
afterAll(() => server.close());
|
||
|
||
it('应获取用户数据', async () => {
|
||
const user = await fetchUser('1');
|
||
expect(user.name).toBe('Alice');
|
||
});
|
||
```
|
||
|
||
## 测试覆盖率
|
||
|
||
### 生成覆盖率报告
|
||
|
||
```bash
|
||
bun run test-app:coverage
|
||
```
|
||
|
||
之后打开 `coverage/index.html` 查看报告。
|
||
|
||
### 覆盖率目标
|
||
|
||
| 类型 | 目标 |
|
||
| ----- | ---- |
|
||
| 关键路径 | 80%+ |
|
||
| 工具函数 | 90%+ |
|
||
| UI 组件 | 70%+ |
|
||
|
||
## 调试测试
|
||
|
||
### VS Code 调试
|
||
|
||
在 `.vscode/launch.json` 中添加:
|
||
|
||
```json
|
||
{
|
||
"type": "node",
|
||
"request": "launch",
|
||
"name": "Debug Vitest",
|
||
"runtimeExecutable": "bun",
|
||
"runtimeArgs": ["x", "vitest", "run", "${file}"],
|
||
"console": "integratedTerminal"
|
||
}
|
||
```
|
||
|
||
### Vitest UI
|
||
|
||
```bash
|
||
bunx vitest --ui
|
||
```
|
||
|
||
在浏览器中打开交互式测试资源管理器。
|
||
|
||
### Console 日志
|
||
|
||
```typescript
|
||
it('应正常工作', () => {
|
||
console.log('调试:', value); // 在测试输出中显示
|
||
expect(value).toBe(expected);
|
||
});
|
||
```
|
||
|
||
## CI/CD 集成
|
||
|
||
GitHub Actions 会在每次 PR 时自动运行以下检查:
|
||
|
||
1. 代码规范(ESLint、Stylelint)
|
||
2. 类型检查
|
||
3. 单元测试
|
||
4. E2E 测试
|
||
5. 构建验证
|
||
|
||
所有检查通过后 PR 才可合并。
|
||
|
||
[vitest-url]: https://vitest.dev/
|