Files
2026-07-13 21:36:47 +08:00

21 KiB
Raw Permalink Blame History

Electron 测试

使用场景:当你的应用是 Electron 桌面应用,并且需要对渲染进程、主进程、IPC 通信、原生对话框、系统托盘和多窗口工作流进行端到端测试时。 前置条件core/configuration.mdcore/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.showOpenDialogdialog.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-builderelectron-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 通过 windowPage)对象在 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"错误 评估期间窗口被关闭或发生了导航 确保窗口在评估前处于稳定状态

相关文档