playwright-electron-config
配置Playwright以全面测试Electron应用程序,包括端到端测试、视觉回归测试、可访问性审计和跨平台测试矩阵。
功能
- 为Electron配置Playwright的
_electron固定装置 - 为Electron窗口生成页面对象模型
- 设置视觉回归测试与快照
- 配置可访问性测试与axe-core
- 创建跨平台测试矩阵用于CI
- 模拟Electron API(对话框、外壳、剪贴板)
- 测试主进程和渲染进程之间的IPC通信
- 生成测试覆盖率报告
输入模式
{
"type": "object",
"properties": {
"projectPath": {
"type": "string",
"description": "Electron项目根路径"
},
"testDir": {
"type": "string",
"default": "tests/e2e"
},
"features": {
"type": "array",
"items": {
"enum": [
"visualRegression",
"accessibility",
"coverage",
"performance",
"ipcTesting",
"multiWindow",
"systemDialogMocks"
]
},
"default": ["visualRegression", "accessibility", "ipcTesting"]
},
"platforms": {
"type": "array",
"items": { "enum": ["windows", "macos", "linux"] },
"default": ["windows", "macos", "linux"]
},
"pageObjects": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"selectors": { "type": "object" }
}
},
"description": "要生成的页面对象"
},
"ciIntegration": {
"type": "object",
"properties": {
"provider": { "enum": ["github-actions", "azure-devops", "circleci", "gitlab"] },
"parallelization": { "type": "boolean", "default": true },
"sharding": { "type": "number", "description": "分片数量" }
}
}
},
"required": ["projectPath"]
}
输出模式
{
"type": "object",
"properties": {
"success": { "type": "boolean" },
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string" },
"type": { "enum": ["config", "fixture", "pageObject", "test", "helper", "ci"] }
}
}
},
"commands": {
"type": "object",
"properties": {
"runTests": { "type": "string" },
"updateSnapshots": { "type": "string" },
"showReport": { "type": "string" }
}
},
"ciWorkflow": {
"type": "string",
"description": "生成的CI工作流文件路径"
}
},
"required": ["success", "files"]
}
生成文件结构
tests/
e2e/
playwright.config.ts # 主Playwright配置
fixtures/
electron-app.ts # Electron固定装置
test-utils.ts # 测试工具
page-objects/
MainWindow.ts # 页面对象模型
SettingsDialog.ts
specs/
app.spec.ts # 应用程序测试
ipc.spec.ts # IPC测试
visual.spec.ts # 视觉回归
a11y.spec.ts # 可访问性测试
mocks/
electron-api-mocks.ts # Electron API模拟
ipc-mocks.ts # IPC模拟
snapshots/ # 视觉快照
reports/ # 测试报告
代码模板
Electron的Playwright配置
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e/specs',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'tests/e2e/reports' }],
['json', { outputFile: 'tests/e2e/reports/results.json' }],
],
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'electron',
testMatch: '**/*.spec.ts',
},
],
});
Electron测试固定装置
// fixtures/electron-app.ts
import { test as base, ElectronApplication, Page } from '@playwright/test';
import { _electron as electron } from 'playwright';
import path from 'path';
export type TestFixtures = {
electronApp: ElectronApplication;
mainWindow: Page;
};
export const test = base.extend<TestFixtures>({
electronApp: async ({}, use) => {
// 启动Electron应用程序
const electronApp = await electron.launch({
args: [path.join(__dirname, '../../dist/main/main.js')],
env: {
...process.env,
NODE_ENV: 'test',
},
});
// 在测试中使用应用程序
await use(electronApp);
// 清理
await electronApp.close();
},
mainWindow: async ({ electronApp }, use) => {
// 等待第一个窗口
const window = await electronApp.firstWindow();
// 等待应用程序准备就绪
await window.waitForLoadState('domcontentloaded');
await use(window);
},
});
export { expect } from '@playwright/test';
页面对象模型
// page-objects/MainWindow.ts
import { Page, Locator } from '@playwright/test';
export class MainWindow {
readonly page: Page;
readonly titleBar: Locator;
readonly sidebar: Locator;
readonly mainContent: Locator;
readonly statusBar: Locator;
constructor(page: Page) {
this.page = page;
this.titleBar = page.locator('[data-testid="title-bar"]');
this.sidebar = page.locator('[data-testid="sidebar"]');
this.mainContent = page.locator('[data-testid="main-content"]');
this.statusBar = page.locator('[data-testid="status-bar"]');
}
async getTitle(): Promise<string> {
return this.page.title();
}
async openSettings(): Promise<void> {
await this.page.click('[data-testid="settings-button"]');
await this.page.waitForSelector('[data-testid="settings-dialog"]');
}
async navigateTo(section: string): Promise<void> {
await this.sidebar.locator(`[data-section="${section}"]`).click();
await this.page.waitForLoadState('networkidle');
}
async screenshot(name: string): Promise<Buffer> {
return this.page.screenshot({ path: `tests/e2e/snapshots/${name}.png` });
}
}
IPC测试
// specs/ipc.spec.ts
import { test, expect } from '../fixtures/electron-app';
test.describe('IPC通信', () => {
test('应向主进程发送消息', async ({ electronApp, mainWindow }) => {
// 在主进程中评估
const result = await electronApp.evaluate(async ({ ipcMain }) => {
return new Promise((resolve) => {
ipcMain.once('test-channel', (event, data) => {
resolve(data);
});
});
});
// 从渲染器发送
await mainWindow.evaluate(() => {
window.electronAPI.send('test-channel', { message: 'hello' });
});
// 验证
expect(result).toEqual({ message: 'hello' });
});
test('应从主进程接收响应', async ({ mainWindow }) => {
const response = await mainWindow.evaluate(async () => {
return window.electronAPI.invoke('get-app-version');
});
expect(response).toMatch(/^\d+\.\d+\.\d+$/);
});
});
视觉回归测试
// specs/visual.spec.ts
import { test, expect } from '../fixtures/electron-app';
import { MainWindow } from '../page-objects/MainWindow';
test.describe('视觉回归', () => {
test('主窗口与快照匹配', async ({ mainWindow }) => {
const page = new MainWindow(mainWindow);
// 等待动画完成
await mainWindow.waitForTimeout(500);
await expect(mainWindow).toHaveScreenshot('main-window.png', {
maxDiffPixels: 100,
});
});
test('暗色模式与快照匹配', async ({ electronApp, mainWindow }) => {
// 通过IPC切换暗色模式
await mainWindow.evaluate(() => {
window.electronAPI.invoke('set-theme', 'dark');
});
await mainWindow.waitForTimeout(300);
await expect(mainWindow).toHaveScreenshot('main-window-dark.png', {
maxDiffPixels: 100,
});
});
test('设置对话框与快照匹配', async ({ mainWindow }) => {
const page = new MainWindow(mainWindow);
await page.openSettings();
const dialog = mainWindow.locator('[data-testid="settings-dialog"]');
await expect(dialog).toHaveScreenshot('settings-dialog.png');
});
});
可访问性测试
// specs/a11y.spec.ts
import { test, expect } from '../fixtures/electron-app';
import AxeBuilder from '@axe-core/playwright';
test.describe('可访问性', () => {
test('主窗口不应有可访问性违规', async ({ mainWindow }) => {
const accessibilityScanResults = await new AxeBuilder({ page: mainWindow })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('键盘导航应工作', async ({ mainWindow }) => {
// 通过Tab键遍历可聚焦元素
await mainWindow.keyboard.press('Tab');
const firstFocused = await mainWindow.evaluate(() =>
document.activeElement?.getAttribute('data-testid')
);
expect(firstFocused).toBeTruthy();
// 验证焦点可见
const focusedElement = mainWindow.locator(':focus');
await expect(focusedElement).toBeVisible();
});
test('屏幕阅读器公告应正确', async ({ mainWindow }) => {
// 检查ARIA标签
const button = mainWindow.locator('[data-testid="save-button"]');
await expect(button).toHaveAttribute('aria-label');
// 检查实时区域
const liveRegion = mainWindow.locator('[aria-live="polite"]');
await expect(liveRegion).toBeAttached();
});
});
Electron API模拟
// mocks/electron-api-mocks.ts
import { ElectronApplication } from '@playwright/test';
export async function mockDialog(
electronApp: ElectronApplication,
response: { filePaths?: string[]; canceled?: boolean }
) {
await electronApp.evaluate(
async ({ dialog }, response) => {
dialog.showOpenDialog = async () => response;
dialog.showSaveDialog = async () => ({
filePath: response.filePaths?.[0],
canceled: response.canceled ?? false,
});
},
response
);
}
export async function mockClipboard(
electronApp: ElectronApplication,
content: string
) {
await electronApp.evaluate(
async ({ clipboard }, content) => {
clipboard.readText = () => content;
clipboard.writeText = () => {};
},
content
);
}
export async function mockShell(electronApp: ElectronApplication) {
const openedUrls: string[] = [];
await electronApp.evaluate(async ({ shell }) => {
shell.openExternal = async (url) => {
// 在测试中跟踪
return true;
};
});
return { openedUrls };
}
GitHub Actions CI工作流
# .github/workflows/e2e-tests.yml
name: E2E测试
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 安装依赖
run: npm ci
- name: 构建Electron应用程序
run: npm run build
- name: 安装Playwright
run: npx playwright install --with-deps
- name: 运行Playwright测试
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: 上传测试工件
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.os }}-${{ matrix.shardIndex }}
path: tests/e2e/reports/
- name: 上传快照
uses: actions/upload-artifact@v4
if: failure()
with:
name: snapshots-${{ matrix.os }}-${{ matrix.shardIndex }}
path: tests/e2e/snapshots/
merge-reports:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: playwright-report-*
merge-multiple: true
path: merged-reports
- name: 合并报告
run: npx playwright merge-reports merged-reports --reporter html
最佳实践
- 使用data-testid属性 - 稳定的选择器用于测试自动化
- 等待应用程序就绪状态 - 在交互之前使用
waitForLoadState() - 模拟外部依赖项 - 对话框、外壳、网络调用
- 在隔离中运行测试 - 按需为每个测试提供新的应用程序实例
- 谨慎使用视觉快照 - 为CI变化允许像素公差
- 在实际平台上测试 - CI中的跨平台矩阵
社区参考
相关技能
electron-builder-config- 构建配置electron-mock-factory- 模拟Electron APIvisual-regression-setup- 视觉测试设置accessibility-test-runner- 可访问性审计
相关代理
desktop-test-architect- 测试策略ui-automation-specialist- UI自动化专长