Files

E2E Testing Guide

本目录包含 Cherry Studio 的端到端 (E2E) 测试,使用 Playwright 测试 Electron 应用。

目录结构

tests/e2e/
├── README.md                 # 本文档
├── global-setup.ts           # 全局测试初始化
├── global-teardown.ts        # 全局测试清理
├── fixtures/
│   └── electron.fixture.ts   # Electron 应用启动 fixture
├── utils/
│   ├── wait-helpers.ts       # 等待辅助函数
│   └── index.ts              # 工具导出
├── pages/                    # Page Object Model
│   ├── base.page.ts          # 基础页面对象类
│   ├── sidebar.page.ts       # 侧边栏导航
│   ├── home.page.ts          # 首页/聊天页
│   ├── settings.page.ts      # 设置页
│   ├── chat.page.ts          # 聊天交互
│   └── index.ts              # 页面对象导出
└── specs/                    # 测试用例
    ├── app-launch.spec.ts    # 应用启动测试
    ├── navigation.spec.ts    # 页面导航测试
    ├── settings/             # 设置相关测试
    │   └── general.spec.ts
    └── conversation/         # 对话相关测试
        └── basic-chat.spec.ts

运行测试

前置条件

  1. 安装依赖:pnpm install
  2. 构建应用:pnpm build

运行命令

# 运行所有 e2e 测试
pnpm test:e2e

# 带可视化窗口运行(可以看到测试过程)
pnpm test:e2e --headed

# 运行特定测试文件
pnpm playwright test tests/e2e/specs/app-launch.spec.ts

# 运行匹配名称的测试
pnpm playwright test -g "should launch"

# 调试模式(会暂停并打开调试器)
pnpm playwright test --debug

# 使用 Playwright UI 模式
pnpm playwright test --ui

# 查看测试报告
pnpm playwright show-report

常见问题

Q: 测试时看不到窗口? A: 默认是 headless 模式,使用 --headed 参数可看到窗口。

Q: 测试失败,提示找不到元素? A:

  1. 确保已运行 pnpm build 构建最新代码
  2. 检查选择器是否正确UI 可能已更新

Q: 测试超时? A: Electron 应用启动较慢,可在测试中增加超时时间:

test.setTimeout(60000) // 60秒

AI 助手指南:创建新测试用例

以下内容供 AI 助手(如 Claude、GPT在创建新测试用例时参考。

基本原则

  1. 使用 Page Object Model (POM):所有页面交互应通过 pages/ 目录下的页面对象进行
  2. 使用自定义 fixture:从 ../fixtures/electron.fixture 导入 testexpect
  3. 等待策略:使用 utils/wait-helpers.ts 中的等待函数,避免硬编码 waitForTimeout
  4. 测试独立性:每个测试应该独立运行,不依赖其他测试的状态

创建新测试文件

// tests/e2e/specs/[feature]/[feature].spec.ts

import { test, expect } from '../../fixtures/electron.fixture'
import { SomePageObject } from '../../pages/some.page'
import { waitForAppReady } from '../../utils/wait-helpers'

test.describe('Feature Name', () => {
  let pageObject: SomePageObject

  test.beforeEach(async ({ mainWindow }) => {
    await waitForAppReady(mainWindow)
    pageObject = new SomePageObject(mainWindow)
  })

  test('should do something', async ({ mainWindow }) => {
    // 测试逻辑
  })
})

创建新页面对象

// tests/e2e/pages/[feature].page.ts

import { Page, Locator } from '@playwright/test'
import { BasePage } from './base.page'

export class FeaturePage extends BasePage {
  // 定义页面元素定位器
  readonly someButton: Locator
  readonly someInput: Locator

  constructor(page: Page) {
    super(page)
    // 使用多种选择器策略,提高稳定性
    this.someButton = page.locator('[class*="SomeButton"], button:has-text("Some Text")')
    this.someInput = page.locator('input[placeholder*="placeholder"]')
  }

  // 页面操作方法
  async doSomething(): Promise<void> {
    await this.someButton.click()
  }

  // 状态检查方法
  async isSomethingVisible(): Promise<boolean> {
    return this.someButton.isVisible()
  }
}

选择器最佳实践

// 优先级从高到低:

// 1. data-testid最稳定但需要在源码中添加
page.locator('[data-testid="submit-button"]')

// 2. 语义化角色
page.locator('button[role="submit"]')
page.locator('[aria-label="Send message"]')

// 3. 类名模糊匹配(适应 CSS Modules / generated class names
page.locator('[class*="SendButton"]')
page.locator('[class*="send-button"]')

// 4. 文本内容
page.locator('button:has-text("发送")')
page.locator('text=Submit')

// 5. 组合选择器(提高稳定性)
page.locator('[class*="ChatInput"] textarea, [class*="InputBar"] textarea')

// 避免使用:
// - 精确类名(容易因构建变化而失效)
// - 层级过深的选择器
// - 索引选择器(如 nth-child除非必要

等待策略

import { waitForAppReady, waitForNavigation, waitForModal } from '../../utils/wait-helpers'

// 等待应用就绪
await waitForAppReady(mainWindow)

// 等待导航完成HashRouter
await waitForNavigation(mainWindow, '/settings')

// 等待模态框出现
await waitForModal(mainWindow)

// 等待元素可见
await page.locator('.some-element').waitFor({ state: 'visible', timeout: 10000 })

// 等待元素消失
await page.locator('.loading').waitFor({ state: 'hidden' })

// 避免使用固定等待时间
// BAD: await page.waitForTimeout(3000)
// GOOD: await page.waitForSelector('.element', { state: 'visible' })

断言模式

// 使用 Playwright 的自动重试断言
await expect(page.locator('.element')).toBeVisible()
await expect(page.locator('.element')).toHaveText('expected text')
await expect(page.locator('.element')).toHaveCount(3)

// 检查 URLHashRouter
await expect(page).toHaveURL(/.*#\/settings.*/)

// 软断言(不会立即失败)
await expect.soft(page.locator('.element')).toBeVisible()

// 自定义超时
await expect(page.locator('.slow-element')).toBeVisible({ timeout: 30000 })

处理 Electron 特性

// 访问 Electron 主进程
const bounds = await electronApp.evaluate(({ BrowserWindow }) => {
  const win = BrowserWindow.getAllWindows()[0]
  return win?.getBounds()
})

// 检查窗口状态
const isMaximized = await electronApp.evaluate(({ BrowserWindow }) => {
  const win = BrowserWindow.getAllWindows()[0]
  return win?.isMaximized()
})

// 调用 IPC通过 preload 暴露的 API
const result = await mainWindow.evaluate(() => {
  return (window as any).api.someMethod()
})

测试文件命名规范

specs/
├── [feature].spec.ts           # 单文件测试
├── [feature]/
│   ├── [sub-feature].spec.ts   # 子功能测试
│   └── [another].spec.ts

示例:

  • app-launch.spec.ts - 应用启动
  • navigation.spec.ts - 页面导航
  • settings/general.spec.ts - 通用设置
  • conversation/basic-chat.spec.ts - 基础聊天

添加新页面对象后的清单

  1. pages/ 目录创建 [feature].page.ts
  2. 继承 BasePage
  3. pages/index.ts 中导出
  4. 在对应的 spec 文件中导入使用

测试用例编写清单

  • 使用自定义 fixture (test, expect)
  • beforeEach 中调用 waitForAppReady
  • 使用 Page Object 进行页面交互
  • 使用描述性的测试名称
  • 添加适当的断言
  • 处理可能的异步操作
  • 考虑测试失败时的清理

调试技巧

// 截图调试
await mainWindow.screenshot({ path: 'debug.png' })

// 打印页面 HTML
console.log(await mainWindow.content())

// 暂停测试进行调试
await mainWindow.pause()

// 打印元素数量
console.log(await page.locator('.element').count())

配置文件

主要配置在项目根目录的 playwright.config.ts

  • testDir: 测试目录 (./tests/e2e/specs)
  • timeout: 测试超时 (60秒)
  • workers: 并发数 (1Electron 需要串行)
  • retries: 重试次数 (CI 环境下为 2)

相关文档