Electron

第九章:自动化测试

第九章:自动化测试

目录


Electron 测试分层

Electron 应用横跨主进程、preload 脚本和渲染进程,测试策略需要分层覆盖:

┌──────────────────────────────────────────────────────────────┐
│                    Electron 测试金字塔                        │
│                                                              │
│                        ╱╲                                    │
│                       ╱  ╲         E2E 测试                  │
│                      ╱ E2E╲        完整应用交互               │
│                     ╱      ╲       数量少,运行慢             │
│                    ╱────────╲                                 │
│                   ╱          ╲     集成测试                   │
│                  ╱  集成测试   ╲    IPC 通信、多进程协作        │
│                 ╱              ╲   数量中等                   │
│                ╱────────────────╲                             │
│               ╱                  ╲  单元测试                  │
│              ╱     单元测试       ╲  纯逻辑函数               │
│             ╱                      ╲ 数量多,运行快           │
│            ╱────────────────────────╲                         │
│                                                              │
│  测试层级          目标                    工具               │
│  ─────────────────────────────────────────────────           │
│  单元测试          工具函数、数据处理       Vitest / Jest      │
│  集成测试          IPC 通信、preload 桥接   Vitest + mock      │
│  E2E 测试          完整用户交互流程         WebdriverIO / PW   │
└──────────────────────────────────────────────────────────────┘

每层测什么

单元测试 — 不需要启动 Electron:
  ✓ 工具函数(路径处理、数据转换、校验逻辑)
  ✓ 状态管理逻辑(store、reducer)
  ✓ preload 脚本中的纯函数部分

集成测试 — 可能需要 mock Electron API:
  ✓ IPC handler 的请求-响应逻辑
  ✓ preload 暴露的 API 是否正确桥接
  ✓ 数据库操作(SQLite 读写)

E2E 测试 — 启动完整应用:
  ✓ 窗口是否正确创建和显示
  ✓ 菜单点击后的行为
  ✓ 用户操作完整流程(打开文件 → 编辑 → 保存)
  ✓ 对话框交互、系统托盘行为

单元测试:Vitest 测试纯逻辑

为什么选 Vitest

Vitest 基于 Vite,启动快、配置简单,天然支持 ESM 和 TypeScript,非常适合 Electron 项目中的纯逻辑测试。

安装与配置

npm install -D vitest
// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // 测试文件匹配模式
    include: ['src/**/*.test.{js,ts}', 'tests/unit/**/*.test.{js,ts}'],
    // 运行环境 — 主进程逻辑用 node,渲染进程逻辑用 jsdom
    environment: 'node',
  },
});

测试主进程工具函数

// src/main/utils.js — 被测代码
const path = require('path');

function sanitizePath(userInput) {
  // 防止路径穿越攻击
  const resolved = path.resolve(userInput);
  const appDir = path.resolve(__dirname, '../../data');
  if (!resolved.startsWith(appDir)) {
    throw new Error('路径越界');
  }
  return resolved;
}

function formatFileSize(bytes) {
  if (bytes === 0) return '0 B';
  const units = ['B', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(1024));
  return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}

module.exports = { sanitizePath, formatFileSize };
// tests/unit/utils.test.js — 测试代码
import { describe, it, expect } from 'vitest';
import { formatFileSize, sanitizePath } from '../../src/main/utils';

describe('formatFileSize', () => {
  it('格式化字节为可读单位', () => {
    expect(formatFileSize(0)).toBe('0 B');
    expect(formatFileSize(1024)).toBe('1.0 KB');
    expect(formatFileSize(1048576)).toBe('1.0 MB');
  });

  it('保留一位小数', () => {
    expect(formatFileSize(1536)).toBe('1.5 KB');
  });
});

describe('sanitizePath', () => {
  it('拒绝路径穿越', () => {
    expect(() => sanitizePath('../../etc/passwd')).toThrow('路径越界');
  });
});

测试 preload 桥接逻辑

preload 脚本依赖 electron 模块,在单元测试中需要 mock:

// tests/unit/preload.test.js
import { describe, it, expect, vi } from 'vitest';

// mock electron 模块
vi.mock('electron', () => ({
  contextBridge: {
    exposeInMainWorld: vi.fn(),
  },
  ipcRenderer: {
    invoke: vi.fn(),
    on: vi.fn(),
  },
}));

import { contextBridge, ipcRenderer } from 'electron';

describe('preload 桥接', () => {
  it('应该通过 contextBridge 暴露 API', async () => {
    // 导入 preload 脚本,触发 exposeInMainWorld
    await import('../../src/preload/index.js');

    expect(contextBridge.exposeInMainWorld).toHaveBeenCalledWith(
      'electronAPI',
      expect.objectContaining({
        openFile: expect.any(Function),
        saveFile: expect.any(Function),
      })
    );
  });

  it('openFile 应该调用正确的 IPC channel', async () => {
    await import('../../src/preload/index.js');

    // 获取暴露的 API 对象
    const exposedAPI = contextBridge.exposeInMainWorld.mock.calls[0][1];
    await exposedAPI.openFile();

    expect(ipcRenderer.invoke).toHaveBeenCalledWith('dialog:openFile');
  });
});

测试 IPC Handler(集成测试)

// tests/integration/ipc-handlers.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';

// mock electron
vi.mock('electron', () => {
  const handlers = new Map();
  return {
    ipcMain: {
      handle: vi.fn((channel, handler) => {
        handlers.set(channel, handler);
      }),
      _getHandler: (channel) => handlers.get(channel),
    },
    dialog: {
      showOpenDialog: vi.fn(),
    },
    BrowserWindow: vi.fn(),
  };
});

import { ipcMain, dialog } from 'electron';
import { registerHandlers } from '../../src/main/ipc-handlers';

describe('IPC handlers', () => {
  beforeEach(() => {
    registerHandlers(); // 注册所有 handler
  });

  it('dialog:openFile 返回选中的文件路径', async () => {
    dialog.showOpenDialog.mockResolvedValue({
      canceled: false,
      filePaths: ['/home/user/doc.txt'],
    });

    const handler = ipcMain._getHandler('dialog:openFile');
    const result = await handler({}, { filters: [] });

    expect(result).toEqual(['/home/user/doc.txt']);
  });

  it('dialog:openFile 用户取消时返回空', async () => {
    dialog.showOpenDialog.mockResolvedValue({
      canceled: true,
      filePaths: [],
    });

    const handler = ipcMain._getHandler('dialog:openFile');
    const result = await handler({});

    expect(result).toEqual([]);
  });
});

E2E 测试:WebdriverIO(官方推荐)

WebdriverIO 搭配 @wdio/electron-service 是 Electron 官方文档当前推荐的 E2E 测试方案。

┌─────────────────────────────────────────────────────────────┐
│               WebdriverIO + Electron 架构                    │
│                                                              │
│  测试脚本 (Node.js)                                          │
│       │                                                      │
│       │  WebDriver 协议                                      │
│       ▼                                                      │
│  ┌──────────────┐     ChromeDriver     ┌──────────────────┐ │
│  │  WDIO Runner │ ◄──────────────────► │ Electron 应用     │ │
│  │              │                      │ (Chromium 内核)   │ │
│  │  断言 & 报告  │                      │                   │ │
│  └──────────────┘                      └──────────────────┘ │
│                                                              │
│  @wdio/electron-service 自动管理:                           │
│  • 启动/关闭 Electron 应用                                   │
│  • ChromeDriver 版本匹配                                     │
│  • 提供 browser.electron.* API                               │
└─────────────────────────────────────────────────────────────┘

安装配置

推荐使用交互式初始化:

npm init wdio@latest
# 选择 "Desktop Testing - of Electron Applications"
# 会自动安装 @wdio/electron-service 及相关依赖

手动安装:

npm install -D @wdio/cli @wdio/local-runner @wdio/mocha-framework \
  @wdio/spec-reporter @wdio/electron-service

wdio.conf.js 配置

// wdio.conf.js
export const config = {
  runner: 'local',
  framework: 'mocha',
  reporters: ['spec'],

  // 测试文件
  specs: ['./tests/e2e/**/*.spec.js'],

  // 使用 electron service
  services: ['electron'],

  // Electron 应用配置
  capabilities: [{
    browserName: 'electron',
    'wdio:electronServiceOptions': {
      // 指向你的应用入口
      appBinaryPath: './out/my-app-darwin-arm64/my-app.app/Contents/MacOS/my-app',
      // 或使用 appEntryPoint 直接从源码启动(开发模式)
      appEntryPoint: './src/main/index.js',
      appArgs: ['--no-sandbox'],
    },
  }],

  // Mocha 超时(Electron 启动较慢)
  mochaOpts: {
    timeout: 30000,
  },
};

基础 E2E 测试

// tests/e2e/app.spec.js
describe('应用启动', () => {
  it('应该创建主窗口', async () => {
    // 获取窗口标题
    const title = await browser.getTitle();
    expect(title).toBe('我的 Electron 应用');
  });

  it('窗口尺寸应该正确', async () => {
    const { width, height } = await browser.getWindowSize();
    expect(width).toBeGreaterThanOrEqual(800);
    expect(height).toBeGreaterThanOrEqual(600);
  });

  it('应该显示欢迎信息', async () => {
    const heading = await $('h1');
    await expect(heading).toHaveText('欢迎使用');
  });
});

用 browser.electron API 访问 Electron 能力

@wdio/electron-service 提供了 browser.electron 命名空间,可以在测试中直接调用 Electron API:

describe('Electron API 交互', () => {
  it('通过 execute 在主进程执行代码', async () => {
    // 在主进程中执行代码,获取 app 信息
    const appName = await browser.electron.execute((electron) => {
      return electron.app.getName();
    });
    expect(appName).toBe('my-app');
  });

  it('获取应用版本', async () => {
    const version = await browser.electron.execute((electron) => {
      return electron.app.getVersion();
    });
    expect(version).toMatch(/^\d+\.\d+\.\d+$/);
  });

  it('检查窗口数量', async () => {
    const windowCount = await browser.electron.execute((electron) => {
      return electron.BrowserWindow.getAllWindows().length;
    });
    expect(windowCount).toBe(1);
  });
});

测试菜单交互

describe('应用菜单', () => {
  it('点击菜单项触发操作', async () => {
    // 通过 Electron API 模拟菜单点击
    await browser.electron.execute((electron) => {
      const menu = electron.Menu.getApplicationMenu();
      // 找到 "文件 → 新建" 菜单项并点击
      const fileMenu = menu.items.find(item => item.label === '文件');
      const newItem = fileMenu.submenu.items.find(item => item.label === '新建');
      newItem.click();
    });

    // 验证新建操作的效果
    const editor = await $('#editor');
    await expect(editor).toHaveText('');
  });
});

测试对话框

原生对话框(dialog.showOpenDialog 等)无法通过 UI 自动化直接操控,需要 mock:

describe('文件对话框', () => {
  it('mock 打开文件对话框', async () => {
    // 在主进程中替换 dialog 实现
    await browser.electron.execute((electron) => {
      const { dialog } = electron;
      // 保存原始方法
      dialog._originalShowOpenDialog = dialog.showOpenDialog;
      // mock 返回值
      dialog.showOpenDialog = async () => ({
        canceled: false,
        filePaths: ['/tmp/test-file.txt'],
      });
    });

    // 触发打开文件操作(点击按钮或菜单)
    const openBtn = await $('#open-file');
    await openBtn.click();

    // 验证文件内容已加载
    const content = await $('#file-content');
    await expect(content).toBeDisplayed();

    // 恢复原始实现
    await browser.electron.execute((electron) => {
      const { dialog } = electron;
      dialog.showOpenDialog = dialog._originalShowOpenDialog;
    });
  });
});

E2E 测试:Playwright(备选方案)

Playwright 通过 _electron.launch() 直接启动 Electron 应用,不需要 WebDriver 协议中转,启动更快。

┌─────────────────────────────────────────────────────────────┐
│               Playwright + Electron 架构                     │
│                                                              │
│  测试脚本 (Node.js)                                          │
│       │                                                      │
│       │  CDP (Chrome DevTools Protocol)                      │
│       ▼                                                      │
│  ┌──────────────┐       直连        ┌──────────────────────┐ │
│  │  Playwright   │ ◄──────────────► │ Electron 应用         │ │
│  │  Test Runner  │                  │ (内置 Chromium)       │ │
│  │              │                   │                       │ │
│  └──────────────┘                   └──────────────────────┘ │
│                                                              │
│  特点:                                                      │
│  • 直连 CDP,无需 ChromeDriver                               │
│  • 支持截图、视频录制                                         │
│  • Playwright 的选择器引擎和自动等待                          │
│  • 注意:_electron 是实验性 API                               │
└─────────────────────────────────────────────────────────────┘

安装与基础使用

npm install -D @playwright/test playwright
// tests/e2e/app.pw.spec.js
const { test, expect, _electron } = require('@playwright/test');

let electronApp;
let window;

test.beforeAll(async () => {
  // 启动 Electron 应用
  electronApp = await _electron.launch({
    args: ['./src/main/index.js'],  // 指向主进程入口
    env: {
      ...process.env,
      NODE_ENV: 'test',
    },
  });

  // 获取第一个窗口
  window = await electronApp.firstWindow();
  // 等待页面加载完毕
  await window.waitForLoadState('domcontentloaded');
});

test.afterAll(async () => {
  await electronApp.close();
});

test('窗口标题正确', async () => {
  const title = await window.title();
  expect(title).toBe('我的应用');
});

test('页面显示欢迎文字', async () => {
  const heading = window.locator('h1');
  await expect(heading).toHaveText('欢迎使用');
});

test('点击按钮触发操作', async () => {
  await window.click('#action-btn');
  const result = await window.locator('#result');
  await expect(result).toBeVisible();
});

在主进程中求值

test('获取主进程信息', async () => {
  // evaluate 在主进程执行代码
  const appPath = await electronApp.evaluate(async ({ app }) => {
    return app.getAppPath();
  });
  expect(appPath).toContain('my-app');
});

test('检查窗口是否可见', async () => {
  const isVisible = await electronApp.evaluate(async ({ BrowserWindow }) => {
    const win = BrowserWindow.getAllWindows()[0];
    return win.isVisible();
  });
  expect(isVisible).toBe(true);
});

截图与调试

test('应用外观截图对比', async () => {
  // 整页截图
  await window.screenshot({ path: 'tests/screenshots/main-window.png' });

  // 元素截图
  const sidebar = window.locator('.sidebar');
  await sidebar.screenshot({ path: 'tests/screenshots/sidebar.png' });
});

WebdriverIO vs Playwright 对比

┌─────────────────────┬──────────────────────┬─────────────────────┐
│                     │ WebdriverIO          │ Playwright          │
├─────────────────────┼──────────────────────┼─────────────────────┤
│ 官方推荐            │ ✅ Electron 官方推荐  │ 社区方案            │
│ 协议                │ WebDriver            │ CDP 直连            │
│ 启动速度            │ 稍慢(经过 Driver)   │ 快                  │
│ API 稳定性          │ 稳定                  │ _electron 实验性    │
│ Electron API 访问   │ browser.electron.*   │ app.evaluate()      │
│ 多浏览器            │ 支持                  │ 支持                │
│ 截图/视频           │ 支持                  │ 原生支持            │
│ 社区 & 文档         │ 丰富                  │ 丰富                │
│ 适合场景            │ 正式项目、CI 流水线   │ 快速验证、原型期    │
└─────────────────────┴──────────────────────┴─────────────────────┘

CI 集成:GitHub Actions

Electron 测试在 CI 上有特殊要求:需要显示服务器(Display Server)来渲染窗口。

Linux 上的 xvfb

Linux CI 通常没有图形界面,需要 xvfb(X Virtual Framebuffer)模拟显示器:

┌──────────────────────────────────────────────────────┐
│                  CI 环境对比                          │
│                                                      │
│  macOS CI:     自带 Display Server  → 直接运行       │
│  Windows CI:   自带 Desktop         → 直接运行       │
│  Linux CI:     无显示服务器         → 需要 xvfb      │
│                                                      │
│  xvfb 原理:                                         │
│  ┌──────────┐    虚拟帧缓冲    ┌──────────────────┐ │
│  │ Electron │ ──────────────► │ xvfb (内存显示器) │ │
│  │   应用   │  渲染到内存      │   不需要物理屏幕  │ │
│  └──────────┘                  └──────────────────┘ │
└──────────────────────────────────────────────────────┘

GitHub Actions 完整配置

# .github/workflows/test.yml
name: Electron Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx vitest run
        name: 运行单元测试

  e2e-test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci

      # 先构建应用(E2E 测试需要打包后的二进制)
      - run: npm run build
        name: 构建应用

      # Linux 需要 xvfb
      - name: E2E 测试 (Linux)
        if: runner.os == 'Linux'
        run: xvfb-run --auto-servernum npx wdio run wdio.conf.js

      # macOS / Windows 直接运行
      - name: E2E 测试 (macOS / Windows)
        if: runner.os != 'Linux'
        run: npx wdio run wdio.conf.js

      # 上传失败截图
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: e2e-screenshots-${{ matrix.os }}
          path: tests/screenshots/

Playwright 的 CI 配置

      # Playwright 同样需要 xvfb
      - name: Playwright E2E (Linux)
        if: runner.os == 'Linux'
        run: xvfb-run --auto-servernum npx playwright test

      - name: Playwright E2E (其他平台)
        if: runner.os != 'Linux'
        run: npx playwright test

常见问题

1. sandbox 对测试的影响

Electron 默认启用 sandbox,某些测试场景可能受限:

// 问题:sandbox 阻止某些 Node.js API
// 解决:在测试时禁用 sandbox(仅测试环境)

// wdio.conf.js
capabilities: [{
  browserName: 'electron',
  'wdio:electronServiceOptions': {
    appArgs: ['--no-sandbox'],  // CI 环境通常需要
  },
}]

// Playwright
electronApp = await _electron.launch({
  args: ['./main.js', '--no-sandbox'],
});

注意--no-sandbox 仅在测试/CI 环境使用,生产环境必须保持 sandbox 开启。

2. 如何 mock 原生对话框

原生对话框(文件选择、消息弹窗)无法通过 UI 自动化操控,有两种策略:

策略一:在测试中替换 dialog 模块(见前文 WebdriverIO 示例)
  优点:简单直接
  缺点:修改了运行时状态

策略二:通过环境变量让应用自行 mock
  ┌──────────────────────────────────────┐
  │  // main.js                          │
  │  if (process.env.E2E_TEST) {         │
  │    dialog.showOpenDialog = async () =>│
  │      ({ canceled: false,             │
  │         filePaths: [testFixture] }); │
  │  }                                   │
  └──────────────────────────────────────┘
  优点:测试代码更干净
  缺点:生产代码中混入测试逻辑

3. 测试覆盖率

# Vitest 内置覆盖率支持
npx vitest run --coverage

# vitest.config.js 中配置
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',       // 'istanbul'
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{js,ts}'],
      exclude: ['src/**/*.test.{js,ts}'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
      },
    },
  },
});

4. 测试运行缓慢

问题:E2E 测试每个用例都启动 / 关闭 Electron 应用

优化方案:
  ✓ 合理使用 beforeAll / afterAll,一次启动,多个用例复用
  ✓ 用例间清理状态而不是重启应用
  ✓ 并行运行独立的测试文件
  ✗ 不要在 beforeEach 中启动应用

5. Spectron 已废弃

⚠️ Spectron 是早期的 Electron 测试方案,已于 2022 年停止维护。
   不支持 Electron 15+。
   迁移方向:WebdriverIO + @wdio/electron-service(官方推荐)

实践建议

┌──────────────────────────────────────────────────────────────┐
│                   Electron 测试最佳实践                       │
│                                                              │
│  1. 分层测试,重心下沉                                        │
│     把尽量多的逻辑抽为纯函数,用单元测试覆盖                   │
│     E2E 只测关键用户路径,不要试图覆盖所有边界                  │
│                                                              │
│  2. 隔离 Electron 依赖                                       │
│     业务逻辑不要直接 require('electron')                      │
│     通过依赖注入或模块分层,让纯逻辑代码可独立测试              │
│                                                              │
│  3. E2E 选型                                                 │
│     正式项目 → WebdriverIO(官方推荐,稳定性好)               │
│     快速验证 → Playwright(启动快,API 直觉)                  │
│                                                              │
│  4. CI 必备                                                  │
│     Linux 上用 xvfb-run 包裹测试命令                          │
│     配置 matrix 覆盖三平台                                    │
│     失败时上传截图 artifact                                    │
│                                                              │
│  5. 测试数据隔离                                              │
│     E2E 测试使用独立的 userData 目录                           │
│     app.setPath('userData', testDataDir)                      │
│     每次测试前清理,避免状态污染                               │
│                                                              │
│  6. 不要过度 mock                                             │
│     mock 太多会让测试失去意义                                  │
│     只 mock 外部依赖(对话框、网络请求、文件系统边界)          │
│     内部逻辑让它真实运行                                       │
│                                                              │
│  7. 推荐测试比例                                              │
│     单元测试 70% / 集成测试 20% / E2E 测试 10%                │
│                                                              │
│  8. package.json scripts                                     │
│     "test":      "vitest run"                                │
│     "test:watch": "vitest"                                   │
│     "test:e2e":  "wdio run wdio.conf.js"                     │
│     "test:coverage": "vitest run --coverage"                 │
└──────────────────────────────────────────────────────────────┘

下一章:我们将学习如何诊断和优化 Electron 应用的性能 →