作者 hustcc 蚂蚁金服·数据体验技术团队

tl;dr 项目地址 jest-electron

一、背景

目前社区上最火热 / 流行的单测框架,必然是 jest。我们前端写单测遇到最多的问题是什么?那必然是无法模拟出真实的浏览器环境。比如:

  • 依赖 dom API 的模块和方法
    • UI 组件
    • canvas 画布
  • 依赖浏览器控制台调试
    • 看 UI 表现
    • 交互过程动画
    • 辅助写单测断言语句

这就是 jest-electron 要做的事情,将 jest 的单测代码放到 electron(底层是 chrome)中去跑,并且可以在 electron 中进行熟悉的前端调试。

二、实现原理

一句话来说,就是通过自定义 jest 的 runner,在这个自定义 runner 中,启动 electron 进程,然后将单测代码的逻辑放到 electron 进程中去跑,最后返回结果。

分成三步内容介绍:

  1. electron
  2. jest runner
  3. 组合能力 -> jest-electron

2.1 electron

个人觉得总体上,electron 的架构和能力还是很清晰明了的,并不会让人觉得晦涩难懂。

简介

Electron 是由 Github 开发,用 HTML,CSS 和 JavaScript 来构建跨平台桌面应用程序的一个开源库。 Electron 通过将 Chromium 和 Node.js 合并到同一个运行时环境中,并将其打包为 Mac,Windows 和 Linux 系统下的应用来实现这一目的。

image.png

main & renderer

我们从 electron 的使用方式来简单窥探一下。

electron index.js

启动之后,就弹出框。

image.png

看 demo 的代码目录其实可以很清晰的看到,代码分成两部分,一部分是 main ,一个部分是 renderer 。怎么区分:

  • main 是在 electron 启动入口文件 index.js 中全部加载的
  • renderer 是在 BrowserWindow 中 load 进去的 html 中加载的

这就是 electron 两个非常重要的概念了。弄懂 main 进程和 render 进程,以及他们之前的通信方式,基本上 electron 的使用就是查 API 了。

image.png
简单归纳一下:

  • main 运行于 node 环境,可以运行获取数据,存储数据等 API
  • renderer 运行于浏览器环境, 可以使用 HTML、CSS、JS 套件做 UI 展示数据

他们之间通过 electron 提供的 ipcMain,ipcRender 两个 ipc API 进行通信。

这样的架构就和我们开发 web 应用没有什么差别了。一个数据层、一个 UI 层,中间提供一些通信机制(web 开发的前端、后端、HTTP 架构)。

进程通信

ipcMain、ipcRenderer 的 API 都继承自 EventEmitter,所以这些 API 都是非常熟悉的了吧。

  1. // 添加下面的代码。
  2. // 引入 ipcRenderer 模块。
  3. import { ipcRenderer } = 'electron';
  4. document.getElementById('button').onclick = function () {
  5. // 使用 ipcRenderer.send 向主进程发送消息。
  6. ipcRenderer.send('asynchronous-message', 'hello world');
  7. }
  8. // 监听主进程返回的消息
  9. ipcRenderer.on('asynchronous-reply', function (event, arg) {
  10. alert(arg);
  11. });

备注:IPC 进程间通信(Inter-Process Communication),指至少两个进程或线程间传送数据或信号的一些技术或方法。

2.2 Jest 自定义 runner

本质上是 jest 将运行单测抽出为 runner 模块,这个 runner 实际是一个 class 类,并且其中只有一个方法 runTests。

runner 的职责是:

  1. 根据用户的 jest 配置决定如何运行所有的单测文件:并行、串行、worker 数量等
  2. 读取文件,根据用户的 preset、transform 等配置,编译源文件
  3. 执行单测,收集 TestResults 数据,包含成功失败、覆盖率等

然后 jest 根据 TestResults 显示测试报告。

一个 runner 骨架类:

  1. /**
  2. * Runner 类
  3. */
  4. export default class ElectronRunner {
  5. private _globalConfig: any;
  6. constructor(globalConfig: any) {
  7. this._globalConfig = globalConfig;
  8. }
  9. // 自定义 runTests 函数
  10. async runTests(
  11. tests: Array<any>,
  12. watcher: any,
  13. onStart: (Test) => void,
  14. onResult: (Test, TestResult) => void,
  15. onFailure: (Test, Error) => void,
  16. ) {
  17. await Promise.all(
  18. tests.map(
  19. throat(concurrency, async test => {
  20. onStart(test);
  21. // 运行单个单测文件
  22. return await runTest({ ... }).then(testResult => {
  23. testResult.failureMessage != null
  24. ? onFailure(test, testResult.failureMessage)
  25. : onResult(test, testResult);
  26. }).catch(error => {
  27. return onFailure(test, error);
  28. });
  29. }),
  30. ),
  31. );
  32. }
  33. }

社区提供了包装,让创建 runner 更加简单:jest-community/create-jest-runner。Jest runner 配置:https://jestjs.io/docs/en/configuration#runner-string

2.3 Jest + Electron

了解了 electron 的使用方式,以及 jest 自定义 runner 的方式。剩下的就是组合逻辑了。

原理

基本的思路是:

  1. 在 jest 自定义 runner 的 runTests 函数中,启动 electron,创建 main 进程
  2. 在 main 进程中创建 BrowserWindow 实例,创建 renderer 进程
  3. runTests 中逐一处理单测数据,将单测数据通过 nodejs 的 process ipc 机制发送到 main 进程中
  4. main 进程通过 electron ipc 通信机制,将单测发送到 renderer 进程
  5. renderer 进程执行单测数据,获取测试结果 TestResults
  6. TestResults 原路返回到 jest

一图胜千言:

image.png

具体的实现逻辑,还是看代码吧!

性能优化:multi-renderer

从实现原理来看,要优化性能,其实没有很多的入手的地方,毕竟只是 jest + electron 的包皮层。

可能唯一可以优化的地方在于利用多 cpu 的计算能力,并发运行多个单测文件。

上述介绍 electron 的知道,一个 main 进程对应多个 renderer 进程,而实际运行单测的环境就是在 renderer 中,所以,我们可以创建一个多 renderer 进程池子

具体实现使用一个 ProcPool 来存储具体 renderer 进程实例,以及它们的是否空闲的状态。运行单测文件的时候,从池子里面取一个 idle 状态的进程,如果不存在则创建一个新的 renderer 进程,同时放入到池子中;运行单测之前将进程状态改成运行中,单测执行完成之后,将进程状态设置为 idle,以便复用。

优化之后测试的效果可以直接看 PR:https://github.com/hustcc/jest-electron/pull/1,结论:

  • jest no-cache 情况下,运行时间降低到之前的 54.5%
  • jest 情况下,运行时间降低到之前的 36.2%

三、使用方式

直接看 GitHub 上的 README.md,使用非常简单,不阻断常规的 Jest 使用。仅支持 Jest 24 版本。

  • 添加 dev 依赖

tnpm i —save-dev jest-electron

  • 修改 jest 配置
  1. {
  2. "jest": {
  3. + "runner": "jest-electron/runner",
  4. + "testEnvironment": "jest-electron/environment"
  5. }
  6. }

就这样就好了,剩下的就是 jest 怎么用就怎么用就行了。

四、一些问题记录

为了提升调试的体验,增加的一些功能和解法。

刷新重新运行

这个运行逻辑是:

  • jest-cli 发送测试,执行 runner 运行
  • runner 将测试发送给 main 进程
  • main 进程找到空闲的 renderer 进程,执行单测
  • jest-cli 获取测试结果显示在 cli 中

那么刷新重新运行的解法就是:

  • main 中运行缓存获取的 tests 数据,然后和对应的 BrowserWindow 关联起来
  • renderer 页面一旦加载成功的时候,发送消息给 main,让 main 将 tests 逐一发送给 renderer 重新运行一遍
  • 当 cli 要给 main 发送 tests 的时候,清空 main 中缓存的 tests 数据,防止重复

electron 控制台打印

因为 jest-runner 这行代码,会默认强制将运行环境中的 console 指定给 jest 自己创建的 BufferConsole 实例,所以单测代码中的 console 语句,均打印到 cli 中了。具体代码如下:

  1. setGlobal(environment.global, 'console', testConsole);

因为 这行代码的执行时间,晚于 自定义的 env,所以只能通过在 env 中 defineProperty 的方式来 mock 掉。

  1. export default class ElectronEnvironment {
  2. private electronWindowConsole: any;
  3. constructor(config: any) {
  4. this.electronWindowConsole = global.console;
  5. this.global = global;
  6. // defineProperty multi-times will throw
  7. try {
  8. // 因为 jest runTest 中会强制设置 console,覆盖掉 electron 的 console 实例
  9. // https://github.com/facebook/jest/blob/6e6a8e827bdf392790ac60eb4d4226af3844cb15/packages/jest-runner/src/runTest.ts#L153
  10. Object.defineProperty(this.global, 'console', {
  11. get: () => {
  12. return this.electronWindowConsole;
  13. },
  14. set: () => {/* do nothing. */},
  15. });
  16. installCommonGlobals(this.global, config.globals);
  17. } catch (e) {}
  18. }
  19. }

通过 defineProperty 强制无法覆盖属性,获取属性的时候,直接使用 electron 浏览器环境的 console。

五、相关轮子

其实单纯学习 electron 造轮子,没啥必要做竞品调研。这里就当相关项目介绍吧。

问题:

  1. 覆盖率无法收集
  2. 调试的 sourcemap 不正确
  3. 能力不足(typescript、less、svg 等都不支持)
  4. 非 jest 生态

后来我们队这个做了一个迭代,增加了 ts 等的支持,但是毕竟非 jest 生态,而且 sourcemap、coverage 问题依然无法解决。

抄了一些代码,但是问题:

  1. 无法保持窗口调试
  2. 运行单测慢(单 main、当 renderer)
  3. 代码晦涩