docs/development/basic/test.zh-CN.mdx
LobeHub 的测试策略包括使用 Vitest 进行单元测试,以及使用 Playwright + Cucumber 进行端到端 (E2E) 测试。本指南介绍如何高效地编写和运行测试。
我们的测试策略包括:
bun run type-check)# 运行指定的单元测试(推荐)
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>
测试文件与被测代码并列放置,命名为 <filename>.test.ts:
src/utils/
├── formatDate.ts
└── formatDate.test.ts
使用 describe 和 it 组织测试用例,使用 beforeEach/afterEach 管理测试前后的状态:
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');
});
});
});
使用 @testing-library/react 测试组件行为:
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();
});
});
});
在 beforeEach 中重置 store 状态,确保测试互相独立:
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');
});
});
});
vi.spyOn 而非 vi.mock// ✅ 推荐 — spyOn 作用域明确,且会自动恢复
vi.spyOn(messageService, 'createMessage').mockResolvedValue('msg_123');
// ❌ 避免 — 全局 mock 容易在测试间产生污染
vi.mock('@/services/message');
// 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 外部库
vi.mock('axios', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: {} })),
},
}));
// Mock 内部模块
vi.mock('@/utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
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 测试:
# 客户端 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'
# 运行所有 E2E 测试
pnpm e2e
# 交互模式(UI 界面)
pnpm e2e:ui
# 仅运行冒烟测试(快速验证)
pnpm test:e2e:smoke
E2E 测试位于 e2e/ 目录下:
e2e/
├── features/ # Cucumber feature 文件(.feature)
│ ├── auth.feature
│ └── chat.feature
├── step-definitions/ # 步骤实现
│ ├── auth.steps.ts
│ └── chat.steps.ts
├── support/ # 共享辅助函数和 hooks
└── playwright.config.ts
Feature 文件 (e2e/features/chat.feature):
Feature: 聊天功能
Scenario: 用户发送消息
Given 我已登录
And 我在聊天页面
When 我输入 "你好,AI!"
And 我点击发送按钮
Then 我应该在聊天中看到我的消息
And 我应该看到 AI 的回复
步骤定义 (e2e/step-definitions/chat.steps.ts):
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();
});
// ✅ 推荐 — 测试用户可感知的行为
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();
});
// ✅ 推荐 — 语义化查询与用户感知一致
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email address');
screen.getByText('Welcome back');
// ❌ 避免 — testId 应作为最后手段
screen.getByTestId('submit-button');
import { beforeEach, afterEach, vi } from 'vitest';
beforeEach(() => {
vi.clearAllMocks(); // 清除 mock 调用历史
vi.clearAllTimers(); // 使用 fake timers 时清除计时器
});
afterEach(() => {
vi.restoreAllMocks(); // 恢复原始实现
});
describe('validateEmail', () => {
it('应接受有效的电子邮件', () => {
expect(validateEmail('[email protected]')).toBe(true);
});
it('应拒绝空字符串', () => {
expect(validateEmail('')).toBe(false);
});
it('应拒绝不含 @ 的邮件', () => {
expect(validateEmail('user.example.com')).toBe(false);
});
it('应拒绝缺少域名的邮件', () => {
expect(validateEmail('user@')).toBe(false);
});
});
// ❌ 避免 — 测试之间共享状态,依赖执行顺序
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');
});
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);
});
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');
});
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');
});
bun run test-app:coverage
之后打开 coverage/index.html 查看报告。
| 类型 | 目标 |
|---|---|
| 关键路径 | 80%+ |
| 工具函数 | 90%+ |
| UI 组件 | 70%+ |
在 .vscode/launch.json 中添加:
{
"type": "node",
"request": "launch",
"name": "Debug Vitest",
"runtimeExecutable": "bun",
"runtimeArgs": ["x", "vitest", "run", "${file}"],
"console": "integratedTerminal"
}
bunx vitest --ui
在浏览器中打开交互式测试资源管理器。
it('应正常工作', () => {
console.log('调试:', value); // 在测试输出中显示
expect(value).toBe(expected);
});
GitHub Actions 会在每次 PR 时自动运行以下检查:
所有检查通过后 PR 才可合并。