21 KiB
Electron 测试
使用场景:当你的应用是 Electron 桌面应用,并且需要对渲染进程、主进程、IPC 通信、原生对话框、系统托盘和多窗口工作流进行端到端测试时。 前置条件:core/configuration.md、core/fixtures-and-hooks.md
快速参考
import { _electron as electron } from "playwright"
// 启动 Electron 应用
const app = await electron.launch({ args: ["./main.js"] })
// 获取第一个窗口(渲染进程)
const window = await app.firstWindow()
// 访问主进程进行评估
const appPath = await app.evaluate(async ({ app }) => {
return app.getPath("userData")
})
// 关闭应用
await app.close()
模式
基础 Electron 应用设置
使用场景:首次使用 Playwright 测试 Electron 应用时。 避免场景:你的应用是 Web 应用,而非 Electron 应用。
TypeScript
import { test, expect, _electron as electron, ElectronApplication, Page } from "@playwright/test"
let app: ElectronApplication
let window: Page
test.beforeAll(async () => {
// 从项目目录启动 Electron 应用
app = await electron.launch({
args: ["./dist/main.js"],
env: {
...process.env,
NODE_ENV: "test",
},
})
// 等待第一个 BrowserWindow 打开
window = await app.firstWindow()
// 可选:等待应用完全加载
await window.waitForLoadState("domcontentloaded")
})
test.afterAll(async () => {
await app.close()
})
test("应用窗口标题正确", async () => {
const title = await window.title()
expect(title).toBe("My Electron App")
})
test("主页面渲染正常", async () => {
await expect(window.getByRole("heading", { name: "Welcome" })).toBeVisible()
})
JavaScript
const { test, expect, _electron: electron } = require("@playwright/test")
let app
let window
test.beforeAll(async () => {
app = await electron.launch({
args: ["./dist/main.js"],
env: {
...process.env,
NODE_ENV: "test",
},
})
window = await app.firstWindow()
await window.waitForLoadState("domcontentloaded")
})
test.afterAll(async () => {
await app.close()
})
test("应用窗口标题正确", async () => {
const title = await window.title()
expect(title).toBe("My Electron App")
})
test("主页面渲染正常", async () => {
await expect(window.getByRole("heading", { name: "Welcome" })).toBeVisible()
})
Electron 应用 Fixture(推荐)
使用场景:你希望跨测试文件获得隔离的、可复用的 Electron 应用实例。 避免场景:所有测试可以共享同一个应用实例(实际中很少见)。
TypeScript
// fixtures.ts
import {
test as base,
expect,
_electron as electron,
ElectronApplication,
Page,
} from "@playwright/test"
type ElectronFixtures = {
electronApp: ElectronApplication
window: Page
}
export const test = base.extend<ElectronFixtures>({
electronApp: async ({}, use) => {
const app = await electron.launch({
args: ["./dist/main.js"],
env: { ...process.env, NODE_ENV: "test" },
})
await use(app)
await app.close()
},
window: async ({ electronApp }, use) => {
const window = await electronApp.firstWindow()
await window.waitForLoadState("domcontentloaded")
await use(window)
},
})
export { expect }
// app.spec.ts
import { test, expect } from "./fixtures"
test("导航到设置页面", async ({ window }) => {
await window.getByRole("link", { name: "Settings" }).click()
await expect(window.getByRole("heading", { name: "Settings" })).toBeVisible()
})
JavaScript
// fixtures.js
const { test: base, expect, _electron: electron } = require("@playwright/test")
const test = base.extend({
electronApp: async ({}, use) => {
const app = await electron.launch({
args: ["./dist/main.js"],
env: { ...process.env, NODE_ENV: "test" },
})
await use(app)
await app.close()
},
window: async ({ electronApp }, use) => {
const window = await electronApp.firstWindow()
await window.waitForLoadState("domcontentloaded")
await use(window)
},
})
module.exports = { test, expect }
访问主进程
使用场景:你需要读取 Electron 应用状态、检查路径、获取应用版本,或验证主进程行为。 避免场景:你需要的一切都在渲染进程(UI)中。优先通过 UI 进行测试。
app.evaluate() 在主进程中运行代码,可访问所有 Electron API。
TypeScript
import { test, expect } from "./fixtures"
test("验证应用版本和路径", async ({ electronApp }) => {
// 在主进程中执行评估——接收 Electron 模块
const appInfo = await electronApp.evaluate(async ({ app }) => {
return {
version: app.getVersion(),
name: app.getName(),
userData: app.getPath("userData"),
locale: app.getLocale(),
isPackaged: app.isPackaged,
}
})
expect(appInfo.version).toMatch(/^\d+\.\d+\.\d+$/)
expect(appInfo.name).toBe("my-electron-app")
expect(appInfo.userData).toBeTruthy()
expect(appInfo.isPackaged).toBe(false) // 开发阶段为 false
})
test("主进程环境变量已设置", async ({ electronApp }) => {
const nodeEnv = await electronApp.evaluate(async () => {
return process.env.NODE_ENV
})
expect(nodeEnv).toBe("test")
})
JavaScript
const { test, expect } = require("./fixtures")
test("验证应用版本和路径", async ({ electronApp }) => {
const appInfo = await electronApp.evaluate(async ({ app }) => {
return {
version: app.getVersion(),
name: app.getName(),
userData: app.getPath("userData"),
isPackaged: app.isPackaged,
}
})
expect(appInfo.version).toMatch(/^\d+\.\d+\.\d+$/)
expect(appInfo.name).toBe("my-electron-app")
})
测试 IPC 通信
使用场景:你的应用使用 ipcMain / ipcRenderer 在主进程和渲染进程之间进行通信。
避免场景:IPC 是实现细节,且其行为完全可以通过 UI 进行测试。
TypeScript
import { test, expect } from "./fixtures"
test("渲染进程发送 IPC 消息并接收响应", async ({ electronApp, window }) => {
// 从渲染进程触发 IPC 调用
const result = await window.evaluate(async () => {
// 假设你的预加载脚本通过 contextBridge 暴露了 ipcRenderer
return await (window as any).electronAPI.getSystemInfo()
})
expect(result).toHaveProperty("platform")
expect(result).toHaveProperty("arch")
expect(result.platform).toBeTruthy()
})
test("主进程处理 IPC 文件读取请求", async ({ electronApp, window }) => {
// 首先在主进程中设置监听器
await electronApp.evaluate(async ({ ipcMain }) => {
ipcMain.handle("test-ping", async () => {
return { pong: true, timestamp: Date.now() }
})
})
// 从渲染进程发送消息
const response = await window.evaluate(async () => {
return await (window as any).electronAPI.invoke("test-ping")
})
expect(response.pong).toBe(true)
expect(response.timestamp).toBeGreaterThan(0)
})
test("IPC 事件触发 UI 更新", async ({ window }) => {
// 模拟主进程向渲染进程发送事件
await window.evaluate(() => {
// 触发应用监听的自定义事件
window.dispatchEvent(
new CustomEvent("app:notification", {
detail: { message: "Update available", version: "2.0.0" },
})
)
})
await expect(window.getByText("Update available")).toBeVisible()
await expect(window.getByText("Version 2.0.0")).toBeVisible()
})
JavaScript
const { test, expect } = require("./fixtures")
test("渲染进程发送 IPC 消息并接收响应", async ({ electronApp, window }) => {
const result = await window.evaluate(async () => {
return await window.electronAPI.getSystemInfo()
})
expect(result).toHaveProperty("platform")
expect(result).toHaveProperty("arch")
})
test("IPC 事件触发 UI 更新", async ({ window }) => {
await window.evaluate(() => {
window.dispatchEvent(
new CustomEvent("app:notification", {
detail: { message: "Update available", version: "2.0.0" },
})
)
})
await expect(window.getByText("Update available")).toBeVisible()
})
文件系统对话框
使用场景:你的应用使用 Electron 的 dialog.showOpenDialog、dialog.showSaveDialog 或类似的原生文件对话框。
避免场景:文件选择由 Web 输入控件(<input type="file">)处理。这种情况应使用标准 Playwright 文件选择器。
原生对话框无法直接交互。在主进程中对它们进行模拟。
TypeScript
import { test, expect } from "./fixtures"
test("打开文件对话框并加载文档", async ({ electronApp, window }) => {
// 模拟对话框返回指定文件路径
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: ["/tmp/test-document.txt"],
})
})
// 点击渲染进程中的"打开文件"按钮
await window.getByRole("button", { name: "Open File" }).click()
// 验证应用已加载文件
await expect(window.getByTestId("file-name")).toHaveText("test-document.txt")
})
test("保存文件对话框返回所选路径", async ({ electronApp, window }) => {
await electronApp.evaluate(async ({ dialog }) => {
dialog.showSaveDialog = async () => ({
canceled: false,
filePath: "/tmp/exported-report.pdf",
})
})
await window.getByRole("button", { name: "Export PDF" }).click()
await expect(window.getByText("Saved to /tmp/exported-report.pdf")).toBeVisible()
})
test("处理取消的文件对话框", async ({ electronApp, window }) => {
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: true,
filePaths: [],
})
})
await window.getByRole("button", { name: "Open File" }).click()
// 应用不应崩溃或改变状态
await expect(window.getByTestId("file-name")).toHaveText("No file selected")
})
JavaScript
const { test, expect } = require("./fixtures")
test("打开文件对话框并加载文档", async ({ electronApp, window }) => {
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: ["/tmp/test-document.txt"],
})
})
await window.getByRole("button", { name: "Open File" }).click()
await expect(window.getByTestId("file-name")).toHaveText("test-document.txt")
})
test("处理取消的文件对话框", async ({ electronApp, window }) => {
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: true,
filePaths: [],
})
})
await window.getByRole("button", { name: "Open File" }).click()
await expect(window.getByTestId("file-name")).toHaveText("No file selected")
})
系统托盘测试
使用场景:你的应用带有包含上下文菜单或状态指示器的系统托盘图标。 避免场景:你的应用没有托盘功能。
Playwright 无法直接点击系统托盘图标。通过在主进程中执行评估来测试托盘逻辑。
TypeScript
import { test, expect } from "./fixtures"
test("应用启动时托盘图标已创建", async ({ electronApp }) => {
const hasTray = await electronApp.evaluate(async ({ BrowserWindow }) => {
// 通过应用存储的引用来访问托盘
const { tray } = require("./tray-manager")
return tray !== null && !tray.isDestroyed()
})
expect(hasTray).toBe(true)
})
test("托盘工具提示显示未读计数", async ({ electronApp }) => {
const tooltip = await electronApp.evaluate(async () => {
const { tray } = require("./tray-manager")
return tray.getToolTip()
})
expect(tooltip).toMatch(/\d+ unread messages?/)
})
test('点击托盘"显示"菜单项可打开窗口', async ({ electronApp }) => {
// 通过调用其回调来模拟点击托盘菜单项
await electronApp.evaluate(async ({ BrowserWindow }) => {
const { trayMenu } = require("./tray-manager")
// 找到"显示"菜单项并调用其点击处理函数
const showItem = trayMenu.items.find((item: any) => item.label === "Show")
if (showItem && showItem.click) {
showItem.click()
}
})
// 主窗口现在应该可见
const window = await electronApp.firstWindow()
const isVisible = await window.evaluate(() => {
return document.visibilityState === "visible"
})
expect(isVisible).toBe(true)
})
JavaScript
const { test, expect } = require("./fixtures")
test("应用启动时托盘图标已创建", async ({ electronApp }) => {
const hasTray = await electronApp.evaluate(async () => {
const { tray } = require("./tray-manager")
return tray !== null && !tray.isDestroyed()
})
expect(hasTray).toBe(true)
})
多窗口
使用场景:你的 Electron 应用打开了多个窗口(偏好设置、关于页面、分离面板)。 避免场景:你的应用使用单个窗口。
TypeScript
import { test, expect } from "./fixtures"
test("打开偏好设置窗口并进行交互", async ({ electronApp, window }) => {
// 点击打开偏好设置窗口的菜单项或按钮
await window.getByRole("menuitem", { name: "Preferences" }).click()
// 等待新窗口出现
const prefsWindow = await electronApp.waitForEvent("window")
await prefsWindow.waitForLoadState("domcontentloaded")
// 与偏好设置窗口交互
await prefsWindow.getByLabel("Theme").selectOption("dark")
await prefsWindow.getByRole("button", { name: "Save" }).click()
// 验证主窗口反映了更改
await expect(window.locator("html")).toHaveAttribute("data-theme", "dark")
// 关闭偏好设置窗口
await prefsWindow.close()
})
test("获取所有打开的窗口", async ({ electronApp, window }) => {
// 打开第二个窗口
await window.getByRole("button", { name: "New Window" }).click()
// 获取所有窗口
const allWindows = electronApp.windows()
expect(allWindows.length).toBe(2)
// 找到新窗口(非主窗口)
const newWindow = allWindows.find((w) => w !== window)!
await expect(newWindow.getByRole("heading")).toBeVisible()
})
JavaScript
const { test, expect } = require("./fixtures")
test("打开偏好设置窗口并进行交互", async ({ electronApp, window }) => {
await window.getByRole("menuitem", { name: "Preferences" }).click()
const prefsWindow = await electronApp.waitForEvent("window")
await prefsWindow.waitForLoadState("domcontentloaded")
await prefsWindow.getByLabel("Theme").selectOption("dark")
await prefsWindow.getByRole("button", { name: "Save" }).click()
await expect(window.locator("html")).toHaveAttribute("data-theme", "dark")
await prefsWindow.close()
})
测试打包/构建后的应用
使用场景:你希望测试 Electron 应用的生产构建版本(经过 electron-builder、electron-forge 等工具打包后)。
避免场景:开发模式测试对你的 CI 管道来说已足够。
TypeScript
import { test, expect, _electron as electron } from "@playwright/test"
import path from "path"
test("打包后的应用启动并正常运行", async () => {
// 打包后应用可执行文件的路径
const appPath =
process.platform === "darwin"
? path.join(__dirname, "../dist/mac/MyApp.app/Contents/MacOS/MyApp")
: process.platform === "win32"
? path.join(__dirname, "../dist/win-unpacked/MyApp.exe")
: path.join(__dirname, "../dist/linux-unpacked/my-app")
const app = await electron.launch({
executablePath: appPath,
})
const window = await app.firstWindow()
await window.waitForLoadState("domcontentloaded")
// 验证打包后的应用运行正常
const title = await window.title()
expect(title).toBe("My Electron App")
await expect(window.getByRole("heading", { name: "Welcome" })).toBeVisible()
// 验证应用报告为已打包状态
const isPackaged = await app.evaluate(async ({ app }) => app.isPackaged)
expect(isPackaged).toBe(true)
await app.close()
})
JavaScript
const { test, expect, _electron: electron } = require("@playwright/test")
const path = require("path")
test("打包后的应用启动并正常运行", async () => {
const appPath =
process.platform === "darwin"
? path.join(__dirname, "../dist/mac/MyApp.app/Contents/MacOS/MyApp")
: process.platform === "win32"
? path.join(__dirname, "../dist/win-unpacked/MyApp.exe")
: path.join(__dirname, "../dist/linux-unpacked/my-app")
const app = await electron.launch({
executablePath: appPath,
})
const window = await app.firstWindow()
await window.waitForLoadState("domcontentloaded")
const title = await window.title()
expect(title).toBe("My Electron App")
await expect(window.getByRole("heading", { name: "Welcome" })).toBeVisible()
await app.close()
})
决策指南
| 场景 | 方法 | 原因 |
|---|---|---|
| 启动 Electron 应用进行测试 | _electron.launch({ args: ['./main.js'] }) |
Playwright 内置的 Electron 支持 |
| 获取主窗口 | app.firstWindow() |
将第一个 BrowserWindow 以 Playwright Page 的形式返回 |
| 读取主进程状态 | app.evaluate(({ app }) => ...) |
在主进程中运行代码,可使用 Electron API |
| 测试 IPC 往返通信 | window.evaluate(渲染进程)+ app.evaluate(主进程) |
覆盖 IPC 桥的两端 |
| 模拟原生文件对话框 | 通过 app.evaluate 覆盖 dialog.showOpenDialog |
原生对话框无法直接自动化 |
| 测试系统托盘 | 通过 app.evaluate 调用托盘回调 |
托盘图标是操作系统原生的,无法通过 Playwright 点击 |
| 多窗口 | app.waitForEvent('window') |
在新 BrowserWindow 实例打开时捕获它们 |
| 测试打包后的构建版本 | electron.launch({ executablePath }) |
指向构建后的二进制文件而非源代码 |
反模式
| 不要这样做 | 问题 | 应这样做 |
|---|---|---|
在测试文件中使用 const { app } = require('electron') |
Electron API 在 Playwright 的 Node 进程中不可用 | 使用 electronApp.evaluate(({ app }) => ...) |
| 将渲染进程代码直接导入测试中 | 绕过了实际的应用生命周期和 IPC | 通过 window(Page)对象在 UI 层面进行测试 |
在 firstWindow() 之后跳过 waitForLoadState |
窗口可能尚未完全渲染 | 始终使用 await window.waitForLoadState('domcontentloaded') |
| 通过点击系统级 UI 来测试托盘 | Playwright 无法与原生操作系统 UI 交互 | 通过 app.evaluate 模拟托盘菜单回调 |
在所有测试中共享同一个 ElectronApplication 实例而不做清理 |
状态在测试之间泄漏 | 使用 fixture,并在拆卸中调用 app.close() |
忘记在 afterAll 中关闭应用 |
留下 Electron 进程持续运行,消耗 CI 资源 | 始终在拆卸中执行 await app.close() |
故障排除
| 症状 | 原因 | 修复方法 |
|---|---|---|
electron.launch() 抛出"cannot find module" |
args 路径未指向你的主入口文件 |
验证路径:args: ['./dist/main.js'] 相对于工作目录 |
firstWindow() 超时 |
应用未及时打开 BrowserWindow |
检查应用是否在启动时创建窗口;增加超时时间 |
app.evaluate 无法访问 Electron 模块 |
解构了错误的参数 | 正确解构:evaluate(async ({ app, dialog, BrowserWindow }) => ...) |
| 对话框模拟未生效 | 模拟在对话框已被调用之后才生效 | 在触发打开对话框的 UI 操作之前设置好模拟 |
| 第二个窗口未被捕获 | waitForEvent('window') 在窗口打开后才注册 |
在触发打开窗口的操作之前注册事件监听器 |
app.close() 后测试挂起 |
应用产生的子进程仍在运行 | 确保你的 Electron 应用在退出时清理子进程 |
| 打包应用测试因路径错误而失败 | 可执行文件路径因操作系统和构建工具而异 | 使用 process.platform 计算正确的路径 |
window.evaluate 抛出"context destroyed"错误 |
评估期间窗口被关闭或发生了导航 | 确保窗口在评估前处于稳定状态 |
相关文档
- core/fixtures-and-hooks.md —— 将 Electron 应用启动封装到 fixture 中
- core/assertions-and-waiting.md —— 所有标准断言均适用于 Electron 窗口
- core/iframes-and-shadow-dom.md —— Electron 应用常嵌入 Web Components 或 iframe
- core/browser-apis.md —— localStorage、IndexedDB 等 API 在 Electron 中的工作方式相同