Files
lobehub__lobehub/docs/development/basic/test.zh-CN.mdx
YuTengjing 191f3fe776 📝 docs: fix outdated content across the docs site (#16964)
* 📝 docs: fix outdated content across the docs site

Audited docs/ against the current codebase and fixed stale content that
drifted after major refactors (Clerk/NextAuth → Better Auth, src/server →
apps/server, src/{config,envs,locales} → packages/*, model-bank updates).

- self-hosting/auth: mark removed NextAuth/Clerk pages with deprecation
  warnings; fix microsoft/zitadel alias claims and the Logto callback path
- environment-variables & advanced: correct wrong defaults (S3_SET_ACL,
  AI_IMAGE_DEFAULT_IMAGE_NUM, index/proxy URLs), drop non-existent env vars
  and fictional feature flags, fix OIDC config guidance
- development: update refactored paths, Better Auth, .mjs configs, remove
  the MSW section (msw is not a dependency)
- providers & model examples: replace retired model ids with in-bank ones;
  fix comfyui frontmatter corruption
- usage: rename Moderator→Orchestrator, GTD→Task, correct shortcut keys,
  channel capability tables, Feishu/Lark WebSocket mode
- platform/migration: fix dead links, docker service name, npx/npm → bun

EN and zh-CN pairs updated together.

* 📝 docs: fix Logto callback path and AUTH_SECRET deprecation guidance

- Revert Logto/Sealos callback URLs to /api/auth/callback/logto to match
  the runtime SSO redirectURI override
- Narrow legacy auth Callout: AUTH_SECRET remains required for Better Auth;
  mark NEXT_AUTH_SECRET as deprecated in zh-CN

* 📝 docs: refresh agent skills for locales, env, auth, and agentRun paths

Align OSS agent guidance with post-refactor layout:
- packages/locales/src/default + bun run i18n
- packages/env/src for env schemas (@/envs alias)
- spa-auth shell + src/routes/auth segments
- chat agentRun/transports/hetero instead of aiChat

* 📝 docs: document APP_URL as required for Better Auth base URL

Self-hosted auth uses appEnv.APP_URL as baseURL, not request-header
auto-detection. NEXT_PUBLIC_AUTH_URL remains deprecated.

* 📝 docs: note self-host search provider setup for web search

Built-in lobe-web-browsing needs no client plugin, but self-hosted
servers still require SEARCH_PROVIDERS / SEARXNG_URL (default SearXNG).

* 📝 docs: point feature guide welcome UI at AgentHome

Opening message/questions on the agent chat page live in
src/features/AgentHome, not Onboarding WelcomeMessage.
2026-07-10 17:50:03 +08:00

537 lines
12 KiB
Plaintext
Raw Permalink 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');
});
```
## 测试覆盖率
### 生成覆盖率报告
```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/