Back to Lobehub

测试指南

docs/development/basic/test.zh-CN.mdx

2.1.5612.0 KB
Original Source

测试指南

LobeHub 的测试策略包括使用 Vitest 进行单元测试,以及使用 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

编写测试用例

使用 describeit 组织测试用例,使用 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('[email protected]')).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 才可合并。