lobehub/docs/development/basic/test.zh-CN.mdx
CanisMinor 43578a9bcc
📝 docs: Polishing and improving product documentation (#12612)
* 🔖 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>
2026-03-03 16:01:41 +08:00

558 lines
12 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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/