主题切换
附录 F:Electron 测试指南
可靠的测试是生产级应用的标配。本附录讲解如何为 Electron 应用建立从单元测试到端到端测试的完整质量保障体系,涵盖 Vitest、Playwright 和 CI/CD 集成的实战方案。
F.1 测试策略概述
Electron 应用包含两个运行时环境——主进程(Node.js)和渲染进程(Chromium),这决定了测试需要分层进行:
| 测试类型 | 测试目标 | 推荐工具 | 运行环境 |
|---|---|---|---|
| 单元测试 | 主进程工具函数、IPC 处理器 | Vitest | Node.js |
| 组件测试 | Vue 组件、渲染进程逻辑 | Vitest + Vue Test Utils | jsdom |
| 集成测试 | 主进程与渲染进程的协作 | Playwright Electron | Electron |
| E2E 测试 | 完整用户流程 | Playwright Electron | Electron |
text
┌─────────────────────────────────────────────────────────┐
│ 测试金字塔 │
├─────────────────────────────────────────────────────────┤
│ │
│ ▲ E2E 测试(少量) │
│ ╱ ╲ Playwright 完整流程 │
│ ╱ ╲ │
│ ╱─────╲ 集成测试(中等) │
│ ╱ IPC ╲ 主进程 + 渲染进程协作 │
│╱─────────╲ │
│─────────── 单元测试(大量) │
│ 工具函数、Vue 组件、Store │
│ │
└─────────────────────────────────────────────────────────┘F.2 Vitest 单元测试
Vitest 是 Vite 生态的原生测试框架,与 electron-vite 项目无缝集成。
主进程逻辑测试
主进程代码运行在 Node.js 环境,测试配置较为直接:
typescript
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// 主进程测试在 Node 环境运行
environment: 'node',
globals: true,
include: [
'src/main/**/*.test.ts',
'src/common/**/*.test.ts'
],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/main/**', 'src/common/**']
}
},
resolve: {
// 确保能解析主进程的别名
alias: {
'@main': '/src/main',
'@common': '/src/common'
}
}
})typescript
import { describe, it, expect } from 'vitest'
import { getUserDataPath, ensureDir } from './path-helper'
import path from 'path'
describe('path-helper', () => {
describe('getUserDataPath', () => {
it('应返回应用数据目录下的子路径', () => {
const result = getUserDataPath('config.json')
expect(result).toContain('config.json')
expect(path.isAbsolute(result)).toBe(true)
})
it('应处理嵌套路径', () => {
const result = getUserDataPath('data/backup/settings.json')
expect(result).toMatch(/data[/\\]backup[/\\]settings\.json$/)
})
})
describe('ensureDir', () => {
it('应创建不存在的目录', async () => {
const testDir = path.join(os.tmpdir(), `test-${Date.now()}`)
await ensureDir(testDir)
expect(fs.existsSync(testDir)).toBe(true)
// 清理
await fs.promises.rmdir(testDir)
})
it('对已存在目录应静默通过', async () => {
const existingDir = os.tmpdir()
await expect(ensureDir(existingDir)).resolves.not.toThrow()
})
})
})IPC 接口的 Mock 测试
测试涉及 IPC 的模块时,需要 Mock Electron 的 API:
typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ipcMain } from 'electron'
// Mock Electron 的 ipcMain
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn(),
on: vi.fn()
},
app: {
getPath: vi.fn((name) => `/mock/path/${name}`)
}
}))
describe('IPC Handlers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('应注册所有预期的 IPC 处理器', () => {
// 动态导入以确保 mock 生效
const { registerHandlers } = require('./handlers')
registerHandlers()
expect(ipcMain.handle).toHaveBeenCalledWith('app:getVersion', expect.any(Function))
expect(ipcMain.handle).toHaveBeenCalledWith('file:read', expect.any(Function))
expect(ipcMain.handle).toHaveBeenCalledWith('file:write', expect.any(Function))
})
it('file:read 处理器应正确读取文件', async () => {
const { registerHandlers } = require('./handlers')
registerHandlers()
// 获取注册的处理器
const readHandler = vi.mocked(ipcMain.handle).mock.calls
.find(([channel]) => channel === 'file:read')?.[1]
expect(readHandler).toBeDefined()
// 调用处理器
const mockEvent = {} as IpcMainInvokeEvent
const result = await readHandler!(mockEvent, '/path/to/file.txt')
expect(result).toBeDefined()
})
})渲染进程组件测试
渲染进程组件使用 jsdom 环境测试:
typescript
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
include: ['src/renderer/**/*.spec.ts'],
// 提供全局 mock
setupFiles: ['./test/renderer-setup.ts']
},
resolve: {
alias: {
'@renderer': '/src/renderer',
'@common': '/src/common'
}
}
})typescript
import { config } from '@vue/test-utils'
// 全局 Mock Electron API
window.electronAPI = {
getAppVersion: vi.fn().mockResolvedValue('1.0.0'),
openFile: vi.fn().mockResolvedValue({ canceled: false, filePaths: ['/test.txt'] }),
onUpdateMessage: vi.fn()
}
// 配置 Vue Test Utils
config.global.stubs = {
// 对复杂子组件使用 stub
'el-table': true,
'el-dialog': true
}typescript
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FlowEditor from './FlowEditor.vue'
describe('FlowEditor', () => {
it('应渲染空的流程画布', () => {
const wrapper = mount(FlowEditor, {
props: { initialNodes: [] }
})
expect(wrapper.find('.flow-canvas').exists()).toBe(true)
expect(wrapper.findAll('.flow-node')).toHaveLength(0)
})
it('点击添加按钮应创建新节点', async () => {
const wrapper = mount(FlowEditor, {
props: { initialNodes: [] }
})
await wrapper.find('[data-testid="add-node-btn"]').trigger('click')
expect(wrapper.findAll('.flow-node')).toHaveLength(1)
expect(wrapper.emitted('update')).toBeTruthy()
})
it('应正确显示节点连接关系', () => {
const wrapper = mount(FlowEditor, {
props: {
initialNodes: [
{ id: '1', type: 'start', x: 0, y: 0 },
{ id: '2', type: 'click', x: 200, y: 0 }
],
initialEdges: [{ source: '1', target: '2' }]
}
})
expect(wrapper.findAll('.flow-edge')).toHaveLength(1)
})
})F.3 Playwright for Electron
Playwright 内置了对 Electron 的支持,可以像测试网页一样测试桌面应用。
配置 Playwright
typescript
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: false, // Electron 测试建议串行
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1, // Electron 应用通常只能运行一个实例
reporter: 'html',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
{
name: 'electron',
use: {
// Electron 特定配置在 fixture 中处理
}
}
]
})typescript
import { test as base, ElectronApplication, Page, _electron as electron } from '@playwright/test'
import path from 'path'
// 定义测试 fixture 类型
interface ElectronFixtures {
electronApp: ElectronApplication
mainPage: Page
}
export const test = base.extend<ElectronFixtures>({
// 自动启动 Electron 应用
electronApp: [async ({}, use) => {
const app = await electron.launch({
args: [path.join(__dirname, '../dist-electron/main.js')],
env: {
...process.env,
NODE_ENV: 'test'
}
})
await use(app)
// 测试结束后关闭应用
await app.close()
}, { scope: 'test' }],
// 获取主窗口页面
mainPage: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow()
await use(page)
}
})
export { expect } from '@playwright/test'窗口操作与菜单测试
typescript
import { test, expect } from './fixtures'
test.describe('应用窗口', () => {
test('启动后应显示主窗口', async ({ mainPage, electronApp }) => {
await expect(mainPage).toHaveTitle(/RPA 桌面助手/)
// 在主进程上下文中获取窗口尺寸(remote 模块已废弃)
const windowBounds = await electronApp.evaluate(async ({ BrowserWindow }) => {
const win = BrowserWindow.getFocusedWindow()
return win?.getBounds() ?? { width: 0, height: 0 }
})
expect(windowBounds.width).toBeGreaterThan(800)
expect(windowBounds.height).toBeGreaterThan(600)
})
test('应能通过菜单打开设置窗口', async ({ electronApp, mainPage }) => {
// 触发应用菜单
await electronApp.evaluate(async ({ BrowserWindow }) => {
const win = BrowserWindow.getFocusedWindow()
win?.webContents.executeJavaScript(`
require('electron').ipcRenderer.send('menu:open-settings')
`)
})
// 等待设置窗口出现
const settingsPage = await electronApp.waitForEvent('window')
await expect(settingsPage).toHaveTitle(/设置/)
await settingsPage.close()
})
test('最小化后应能在系统托盘恢复', async ({ electronApp, mainPage }) => {
// 最小化窗口(在主进程上下文中操作)
await electronApp.evaluate(async ({ BrowserWindow }) => {
const win = BrowserWindow.getFocusedWindow()
win?.minimize()
})
// 通过 IPC 触发托盘恢复
await electronApp.evaluate(async ({ Tray }) => {
// 模拟点击托盘图标
const tray = Tray.getAllTrays()[0]
tray.emit('click')
})
// 验证窗口恢复(在主进程上下文中检查)
const isVisible = await electronApp.evaluate(async ({ BrowserWindow }) => {
const win = BrowserWindow.getFocusedWindow()
return win?.isVisible() ?? false
})
expect(isVisible).toBe(true)
})
})对话框处理
typescript
import { test, expect } from './fixtures'
test.describe('对话框交互', () => {
test('应能处理文件选择对话框', async ({ mainPage }) => {
// 监听对话框事件并自动处理
mainPage.on('dialog', async dialog => {
if (dialog.type() === 'filechooser') {
await dialog.accept(['/mock/test-file.txt'])
} else {
await dialog.dismiss()
}
})
// 触发文件选择
await mainPage.click('[data-testid="select-file-btn"]')
// 验证文件被选中
await expect(mainPage.locator('[data-testid="selected-file"]')).
toHaveText('test-file.txt')
})
test('应能处理确认对话框', async ({ mainPage }) => {
// 设置对话框处理为"确认"
mainPage.on('dialog', dialog => dialog.accept())
await mainPage.click('[data-testid="delete-btn"]')
// 验证删除成功
await expect(mainPage.locator('.success-message')).toBeVisible()
})
})F.4 E2E 测试实战
完整自动化流程测试
typescript
import { test, expect } from './fixtures'
test.describe('自动化流程', () => {
test('应能创建并执行简单的点击流程', async ({ mainPage }) => {
// 1. 创建新流程
await mainPage.click('[data-testid="new-flow-btn"]')
await mainPage.fill('[data-testid="flow-name-input"]', '测试点击流程')
await mainPage.click('[data-testid="confirm-create-btn"]')
// 2. 添加点击节点
await mainPage.click('[data-testid="add-click-node"]')
await mainPage.fill('[data-testid="x-input"]', '100')
await mainPage.fill('[data-testid="y-input"]', '200')
await mainPage.click('[data-testid="save-node-btn"]')
// 3. 保存流程
await mainPage.click('[data-testid="save-flow-btn"]')
await expect(mainPage.locator('.save-success')).toBeVisible()
// 4. 执行流程(使用模拟模式,不实际操作鼠标)
await mainPage.click('[data-testid="run-flow-btn"]')
await mainPage.click('[data-testid="run-simulation-mode"]')
// 5. 验证执行结果
await expect(mainPage.locator('[data-testid="execution-status"]')).
toHaveText('执行成功')
const logEntries = await mainPage.locator('.log-entry').count()
expect(logEntries).toBeGreaterThan(0)
})
test('流程执行失败时应显示错误详情', async ({ mainPage }) => {
// 创建一个故意会失败的流程(坐标超出屏幕)
await mainPage.click('[data-testid="new-flow-btn"]')
await mainPage.fill('[data-testid="flow-name-input"]', '错误流程')
await mainPage.click('[data-testid="confirm-create-btn"]')
await mainPage.click('[data-testid="add-click-node"]')
await mainPage.fill('[data-testid="x-input"]', '99999')
await mainPage.fill('[data-testid="y-input"]', '99999')
await mainPage.click('[data-testid="save-node-btn"]')
await mainPage.click('[data-testid="run-flow-btn"]')
// 验证错误状态
await expect(mainPage.locator('[data-testid="execution-status"]')).
toHaveText('执行失败')
await expect(mainPage.locator('.error-detail')).toContainText('坐标超出屏幕范围')
})
})截图对比测试(视觉回归)
typescript
import { test, expect } from './fixtures'
test.describe('视觉回归测试', () => {
test('流程编辑器界面应保持一致', async ({ mainPage }) => {
// 导航到流程编辑器
await mainPage.click('[data-testid="nav-flow-editor"]')
// 等待画布渲染完成
await mainPage.waitForSelector('.flow-canvas', { state: 'visible' })
await mainPage.waitForTimeout(500) // 等待动画完成
// 截图并与基准对比
expect(await mainPage.screenshot({
animations: 'disabled'
})).toMatchSnapshot('flow-editor.png')
})
test('暗黑/亮色模式切换应正常', async ({ mainPage }) => {
// 截图暗色模式
await mainPage.click('[data-testid="theme-toggle"]')
await mainPage.waitForTimeout(300)
const lightScreenshot = await mainPage.screenshot()
expect(lightScreenshot).toMatchSnapshot('light-mode.png')
// 切回暗色模式
await mainPage.click('[data-testid="theme-toggle"]')
await mainPage.waitForTimeout(300)
})
})F.5 CI/CD 中的测试
在持续集成环境中运行 Electron 测试需要特殊配置。
GitHub Actions 配置
yaml
name: Test
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run unit tests
run: pnpm test:unit
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
e2e-tests:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Build app
run: pnpm build
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report-${{ matrix.os }}
path: playwright-report/Headless 模式配置
CI 环境通常没有显示器,需要使用 headless 模式:
typescript
export const test = base.extend<ElectronFixtures>({
electronApp: [async ({}, use) => {
const isCI = process.env.CI === 'true'
const app = await electron.launch({
args: [path.join(__dirname, '../dist-electron/main.js')],
env: { ...process.env, NODE_ENV: 'test' },
// CI 环境下使用 headless
...(isCI && {
// Linux 需要 xvfb
env: {
...process.env,
DISPLAY: ':99'
}
})
})
await use(app)
await app.close()
}, { scope: 'test' }]
})yaml
e2e-tests-linux:
runs-on: ubuntu-latest
steps:
# ... 前置步骤
- name: Run E2E tests (headless)
run: |
xvfb-run --auto-servernum --server-args='-screen 0 1280x960x24' \
pnpm test:e2epackage.json 脚本配置
json
{
"scripts": {
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:coverage": "vitest run --coverage",
"test": "pnpm test:unit && pnpm test:e2e"
}
}测试数据管理
E2E 测试中避免使用生产数据库。推荐方案: • 使用内存 SQLite 或临时文件数据库 • 每个测试用例前重置应用状态 • 使用 test.fixme() 标记已知失败的测试,避免阻塞 CI