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