# Electron 测试 > **使用场景**:当你的应用是 Electron 桌面应用,并且需要对渲染进程、主进程、IPC 通信、原生对话框、系统托盘和多窗口工作流进行端到端测试时。 > **前置条件**:[core/configuration.md](configuration.md)、[core/fixtures-and-hooks.md](fixtures-and-hooks.md) ## 快速参考 ```typescript 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** ```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** ```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** ```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({ 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 } ``` ```typescript // 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** ```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** ```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** ```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** ```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** ```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 输入控件(``)处理。这种情况应使用标准 Playwright 文件选择器。 原生对话框无法直接交互。在主进程中对它们进行模拟。 **TypeScript** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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](fixtures-and-hooks.md) —— 将 Electron 应用启动封装到 fixture 中 - [core/assertions-and-waiting.md](assertions-and-waiting.md) —— 所有标准断言均适用于 Electron 窗口 - [core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) —— Electron 应用常嵌入 Web Components 或 iframe - [core/browser-apis.md](browser-apis.md) —— localStorage、IndexedDB 等 API 在 Electron 中的工作方式相同