人人都会AI编程

15.5 单元测试与端到端测试方案

更新时间:2026-07-11

测试是桌面应用质量的生命线,尤其是当应用需要在多个平台运行、且功能会随着迭代频繁变化时。Electron 的特殊架构——主进程与渲染进程分离——让测试方案需要分层思考:主进程逻辑要当成 Node.js 服务来测,渲染进程则当成常规前端项目测,而端到端测试则必须覆盖整个应用的真实交互。这套组合拳一旦建好,就能在每次发版前用 CI 自动跑完,省去大量人工回归的精力。

15.5.1 单元测试选型

目前 Electron 生态中最主流的测试框架是 VitestJest。两者都支持 TypeScript、快照测试、模拟模块,而且都能很好地适配 Electron 的“一部分代码运行在 Node 环境,一部分运行在浏览器环境”的特点。本教程以 Vitest 为例,因为它基于 Vite,与我们的构建工具天然一致,配置更简洁,速度也更快。

安装基础依赖:

npm install -D vitest @vitest/ui happy-dom

happy-dom 是一个轻量级 DOM 实现,用于在测试环境中模拟浏览器 API,比 jsdom 更快,也更适合大多数 Electron 渲染进程模块的测试。

主进程测试与渲染进程测试最好分开配置。推荐在项目根目录下创建 vitest.config.main.tsvitest.config.renderer.ts 两个配置文件,然后通过 package.json 的脚本区分调用。

主进程配置示例(vitest.config.main.ts):

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',          // 主进程代码运行在 Node 环境
    include: ['src/main/**/*.test.ts'],
  },
});

渲染进程配置示例(vitest.config.renderer.ts):

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'happy-dom',     // 模拟浏览器环境
    include: ['src/renderer/**/*.test.ts', 'src/renderer/**/*.test.tsx'],
  },
});

然后在 package.json 中添加脚本:

{
  "scripts": {
    "test:main": "vitest run --config vitest.config.main.ts",
    "test:renderer": "vitest run --config vitest.config.renderer.ts",
    "test": "npm run test:main && npm run test:renderer"
  }
}

15.5.2 主进程单元测试

主进程的代码通常包含对 Electron 内置模块(appBrowserWindowipcMain)的依赖。在测试环境中,这些模块需要被模拟,否则会因为缺少真正的 Electron 运行时而报错。常见做法有两种:

  • 使用 vitestvi.mock 手动模拟整个 electron 模块。
  • 使用社区方案如 electron-vite 内置的测试支持,或直接引入 @electron/remote 这类辅助库(但更推荐前一种,因为它更可控)。

一个典型的 IPC 处理函数测试:

// src/main/ipc-handlers.ts
import { ipcMain } from 'electron';

export function registerHandlers() {
  ipcMain.handle('get-app-version', () => {
    return '1.0.0';
  });
}

测试文件:

// src/main/ipc-handlers.test.ts
import { describe, it, expect, vi } from 'vitest';

// 必须在文件顶部模拟 electron 模块
vi.mock('electron', () => {
  const handleMap = new Map();
  return {
    ipcMain: {
      handle: vi.fn((channel, handler) => {
        handleMap.set(channel, handler);
      }),
    },
    // 辅助函数,方便在测试中模拟调用
    __test__handleMap: handleMap,
  };
});

import { registerHandlers } from './ipc-handlers';
import electron from 'electron';

describe('IPC handlers', () => {
  it('should return app version', async () => {
    registerHandlers();
    // 从模拟的 map 中取出注册的回调并调用
    const handler = (electron as any).__test__handleMap.get('get-app-version');
    const result = await handler();
    expect(result).toBe('1.0.0');
  });
});

要点:

  • 模拟越轻越好,只模拟你实际使用了的方法。
  • 对于更复杂的类(如 BrowserWindow),可以返回一个包含必需属性和方法的简单对象,或使用 vi.fn()

15.5.3 渲染进程单元测试

渲染进程的测试本质上和普通的前端组件测试没有区别。你仍然可以使用 Vue Test Utils、React Testing Library 或类似方案,唯一需要注意的是预加载脚本暴露的全局 API(如 window.electronAPI)需要模拟。

以 Vue 3 + Vue Test Utils 为例,测试一个使用了 window.api.send 的组件:

// src/renderer/components/VersionButton.test.ts
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';

// 模拟 window.api
window.api = {
  send: vi.fn(),
  receive: vi.fn(),
};

import VersionButton from './VersionButton.vue';

describe('VersionButton', () => {
  it('should call api.send when clicked', async () => {
    const wrapper = mount(VersionButton);
    await wrapper.find('button').trigger('click');
    expect(window.api.send).toHaveBeenCalledWith('get-version');
  });
});

如果你的预加载脚本在实际运行时会做类型检查或复杂的 Proxy 封装,可以通过 vi.stubGlobal 或直接赋值 window.xxx 的方式来做模拟,保持测试简单直接。

对于工具函数、状态管理逻辑(Pinia / Vuex),直接导入测试即可,完全不需要特殊处理。这正是多进程分离带来的好处:界面逻辑与系统能力解耦,测试起来非常纯粹。

15.5.4 端到端测试方案

端到端(E2E)测试的目标是验证整个应用从启动到业务流程完成的完整通路,包括多窗口、原生对话框、托盘菜单等真实场景。当前社区最推荐的方案是 Playwright + Electron,因为 Playwright 原生支持与 Electron 应用的通信,而且其 API 统一、调试体验极佳。

15.5.4.1 安装与配置

npm install -D @playwright/test electron playwright

Playwright 的官方 Electron 支持需要通过 electron.launch 的方式启动应用。在测试文件中,你可以直接创建一个 Electron 应用实例,然后像测试普通网页一样操作窗口。

典型的 Playwright 配置(playwright.config.ts):

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  timeout: 30_000,
  expect: {
    timeout: 5_000,
  },
  // 不指定 projects,因为我们只测 Electron,不需要多浏览器
});

15.5.4.2 编写测试用例

首先,我们需要一个工具函数来启动 Electron 应用并获得 Playwright 的 Page 对象。

创建 e2e/helpers.ts

import { _electron as electron } from 'playwright';
import { ElectronApplication, Page } from '@playwright/test';

export async function launchApp(): Promise<{ app: ElectronApplication; page: Page }> {
  // 启动 Electron 应用,main.js 是主进程入口
  const app = await electron.launch({
    args: ['.'],  // 指向项目根目录,Electron 会查找 package.json 中的 main 字段
  });

  // 等待主窗口出现
  const page = await app.firstWindow();
  return { app, page };
}

测试示例(e2e/app.spec.ts):

import { test, expect } from '@playwright/test';
import { launchApp } from './helpers';

test('应用启动并显示标题', async () => {
  const { app, page } = await launchApp();
  // 检查主窗口标题
  await expect(page).toHaveTitle('My App');

  // 点击某个按钮
  const btn = page.locator('button#check-version');
  await btn.click();

  // 验证弹窗或文案变化
  const versionLabel = page.locator('#version');
  await expect(versionLabel).toHaveText('v1.0.0');

  await app.close();
});

test('文件对话框交互', async () => {
  const { app, page } = await launchApp();

  // 点击“打开文件”按钮
  await page.click('button#open-file');

  // Playwright 会自动处理原生对话框吗?
  // Electron 的 dialog.showOpenDialog 是原生对话框,Playwright 无法直接操作。
  // 需要用 app.evaluate() 在主进程中预先 mock 或监听。
  // 这里演示一个更可靠的做法:通过 IPC 暴露方法供测试时调用。

  await app.close();
});

处理原生对话框的关键技巧:由于 Playwright 运行在测试进程,无法直接操作系统级对话框,所以我们需要在测试模式下通过 IPC 注入控制。

在应用主进程中,可以增加一个仅用于测试的 IPC 通道:

// main.js 中
if (process.env.NODE_ENV === 'test') {
  ipcMain.handle('test:mock-dialog', async () => {
    // 返回一个模拟的文件路径,绕过真实的系统对话框
    return '/fake/path/to/file.txt';
  });
}

然后在测试代码中使用 app.evaluate() 在主进程环境执行代码:

test('文件对话框交互(mock)', async () => {
  const { app, page } = await launchApp();

  // 在主进程中触发 Mock
  await app.evaluate(({ ipcMain }) => {
    ipcMain.removeHandler('dialog:open');
    ipcMain.handle('dialog:open', async () => {
      return { filePaths: ['/test/file.txt'], canceled: false };
    });
  });

  await page.click('button#open-file');
  const pathDisplay = page.locator('#file-path');
  await expect(pathDisplay).toHaveText('/test/file.txt');

  await app.close();
});

15.5.4.3 测试系统托盘和菜单

托盘和菜单的交互更偏向主进程逻辑,可以用 app.evaluate() 直接测试相关模块的函数返回值,而不必真的让测试环境去点击系统托盘图标(这在不同操作系统上行为不一致且不可靠)。将核心逻辑抽象为可测试的纯函数,然后通过 E2E 验证应用启动后托盘图标是否正常创建,菜单项点击是否触发对应的 IPC 即可。

15.5.4.4 CI 集成

在 GitHub Actions 等 CI 环境中运行 E2E 测试时,需要注意 Electron 需要图形环境。Linux 服务器通常没有显示器,此时必须使用 xvfb-run 来提供虚拟帧缓冲。Playwright 已经内置了对 xvfb 的支持,只需在 CI 脚本中直接运行 npx playwright test 即可。确保 CI 运行环境安装了必要的系统依赖(如 libgtk-3-0、libnotify4 等),Playwright 官方文档提供了针对各 Linux 发行版的安装指引。

15.5.5 测试方案选择速查表

| 测试类型 | 工具 | 适用场景 |
|---------|------|---------|
| 主进程单元测试 | Vitest + vi.mock | IPC 处理函数、菜单构建器、数据库操作、文件系统封装 |
| 渲染进程单元测试 | Vitest + Testing Library / Vue Test Utils | 组件交互、状态管理、工具函数 |
| 预加载脚本测试 | Vitest + 模拟 contextBridge | 安全桥接函数、通信协议验证 |
| 端到端测试 | Playwright + Electron | 完整用户流程、窗口管理、异常恢复、跨进程协同 |

将这三层测试组合起来,你可以在提交代码前用单元测试快速反馈逻辑正确性,在合并主干前用 E2E 测试保证业务链条的完整性。而且整套方案只需要一个 npm 脚本就能串联执行,非常适合集成到现代 CI/CD 工作流中。至此,一个具备工程化测试保障的 Electron 项目就真正立住了。