作者 hustcc 蚂蚁金服·数据体验技术团队
tl;dr 项目地址 jest-electron。
一、背景
目前社区上最火热 / 流行的单测框架,必然是 jest。我们前端写单测遇到最多的问题是什么?那必然是无法模拟出真实的浏览器环境。比如:
- 依赖 dom API 的模块和方法
- UI 组件
- canvas 画布
- …
- 依赖浏览器控制台调试
- 看 UI 表现
- 交互过程动画
- 辅助写单测断言语句
这就是 jest-electron 要做的事情,将 jest 的单测代码放到 electron(底层是 chrome)中去跑,并且可以在 electron 中进行熟悉的前端调试。
二、实现原理
一句话来说,就是通过自定义 jest 的 runner,在这个自定义 runner 中,启动 electron 进程,然后将单测代码的逻辑放到 electron 进程中去跑,最后返回结果。
分成三步内容介绍:
- electron
- jest runner
- 组合能力 -> jest-electron
2.1 electron
个人觉得总体上,electron 的架构和能力还是很清晰明了的,并不会让人觉得晦涩难懂。
简介
Electron 是由 Github 开发,用 HTML,CSS 和 JavaScript 来构建跨平台桌面应用程序的一个开源库。 Electron 通过将 Chromium 和 Node.js 合并到同一个运行时环境中,并将其打包为 Mac,Windows 和 Linux 系统下的应用来实现这一目的。
main & renderer
我们从 electron 的使用方式来简单窥探一下。
electron index.js
启动之后,就弹出框。
看 demo 的代码目录其实可以很清晰的看到,代码分成两部分,一部分是 main
,一个部分是 renderer
。怎么区分:
- main 是在 electron 启动入口文件 index.js 中全部加载的
- renderer 是在 BrowserWindow 中 load 进去的 html 中加载的
这就是 electron 两个非常重要的概念了。弄懂 main 进程和 render 进程,以及他们之前的通信方式,基本上 electron 的使用就是查 API 了。
简单归纳一下:
- main 运行于 node 环境,可以运行获取数据,存储数据等 API
- renderer 运行于浏览器环境, 可以使用 HTML、CSS、JS 套件做 UI 展示数据
他们之间通过 electron 提供的 ipcMain,ipcRender 两个 ipc API 进行通信。
这样的架构就和我们开发 web 应用没有什么差别了。一个数据层、一个 UI 层,中间提供一些通信机制(web 开发的前端、后端、HTTP 架构)。
进程通信
ipcMain、ipcRenderer 的 API 都继承自 EventEmitter,所以这些 API 都是非常熟悉的了吧。
// 添加下面的代码。
// 引入 ipcRenderer 模块。
import { ipcRenderer } = 'electron';
document.getElementById('button').onclick = function () {
// 使用 ipcRenderer.send 向主进程发送消息。
ipcRenderer.send('asynchronous-message', 'hello world');
}
// 监听主进程返回的消息
ipcRenderer.on('asynchronous-reply', function (event, arg) {
alert(arg);
});
备注:IPC 进程间通信(Inter-Process Communication),指至少两个进程或线程间传送数据或信号的一些技术或方法。
2.2 Jest 自定义 runner
本质上是 jest 将运行单测抽出为 runner 模块,这个 runner 实际是一个 class 类,并且其中只有一个方法 runTests。
runner 的职责是:
- 根据用户的 jest 配置决定如何运行所有的单测文件:并行、串行、worker 数量等
- 读取文件,根据用户的 preset、transform 等配置,编译源文件
- 执行单测,收集 TestResults 数据,包含成功失败、覆盖率等
然后 jest 根据 TestResults 显示测试报告。
一个 runner 骨架类:
/**
* Runner 类
*/
export default class ElectronRunner {
private _globalConfig: any;
constructor(globalConfig: any) {
this._globalConfig = globalConfig;
}
// 自定义 runTests 函数
async runTests(
tests: Array<any>,
watcher: any,
onStart: (Test) => void,
onResult: (Test, TestResult) => void,
onFailure: (Test, Error) => void,
) {
await Promise.all(
tests.map(
throat(concurrency, async test => {
onStart(test);
// 运行单个单测文件
return await runTest({ ... }).then(testResult => {
testResult.failureMessage != null
? onFailure(test, testResult.failureMessage)
: onResult(test, testResult);
}).catch(error => {
return onFailure(test, error);
});
}),
),
);
}
}
社区提供了包装,让创建 runner 更加简单:jest-community/create-jest-runner。Jest runner 配置:https://jestjs.io/docs/en/configuration#runner-string
2.3 Jest + Electron
了解了 electron 的使用方式,以及 jest 自定义 runner 的方式。剩下的就是组合逻辑了。
原理
基本的思路是:
- 在 jest 自定义 runner 的 runTests 函数中,启动 electron,创建 main 进程
- 在 main 进程中创建 BrowserWindow 实例,创建 renderer 进程
- runTests 中逐一处理单测数据,将单测数据通过 nodejs 的 process ipc 机制发送到 main 进程中
- main 进程通过 electron ipc 通信机制,将单测发送到 renderer 进程
- renderer 进程执行单测数据,获取测试结果 TestResults
- TestResults 原路返回到 jest
一图胜千言:
具体的实现逻辑,还是看代码吧!
性能优化: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 配置
{
"jest": {
+ "runner": "jest-electron/runner",
+ "testEnvironment": "jest-electron/environment"
}
}
就这样就好了,剩下的就是 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 中了。具体代码如下:
setGlobal(environment.global, 'console', testConsole);
因为 这行代码的执行时间,晚于 自定义的 env,所以只能通过在 env 中 defineProperty 的方式来 mock 掉。
export default class ElectronEnvironment {
private electronWindowConsole: any;
constructor(config: any) {
this.electronWindowConsole = global.console;
this.global = global;
// defineProperty multi-times will throw
try {
// 因为 jest runTest 中会强制设置 console,覆盖掉 electron 的 console 实例
// https://github.com/facebook/jest/blob/6e6a8e827bdf392790ac60eb4d4226af3844cb15/packages/jest-runner/src/runTest.ts#L153
Object.defineProperty(this.global, 'console', {
get: () => {
return this.electronWindowConsole;
},
set: () => {/* do nothing. */},
});
installCommonGlobals(this.global, config.globals);
} catch (e) {}
}
}
通过 defineProperty 强制无法覆盖属性,获取属性的时候,直接使用 electron 浏览器环境的 console。
五、相关轮子
其实单纯学习 electron 造轮子,没啥必要做竞品调研。这里就当相关项目介绍吧。
问题:
- 覆盖率无法收集
- 调试的 sourcemap 不正确
- 能力不足(typescript、less、svg 等都不支持)
- 非 jest 生态
后来我们队这个做了一个迭代,增加了 ts 等的支持,但是毕竟非 jest 生态,而且 sourcemap、coverage 问题依然无法解决。
抄了一些代码,但是问题:
- 无法保持窗口调试
- 运行单测慢(单 main、当 renderer)
- 代码晦涩