前端代码覆盖率实时统计方案探索

背景

随着业务增长,代码逐渐复杂,前端有些时候不能通过自测保证代码质量,而测试同学从用户角度进行端到端的case测试,也有可能存在测试case不够全面或重复覆盖的情况。而测试覆盖质量只能通过测试case评审来保证,没有量化的、直观的客观数据。

然而前端场景和后端不同,UI测试因为业务形态原因,变更会更频繁,编写和维护单测成本比较高。因此目前且短期内前端工程还是以传统的用户角度的端到端测试为主。另外,在SSR项目中,前端开发也会书写API层,这部分代码也需要测试覆盖到。目前前端生态还没有一个能同时覆盖UI层和API层的覆盖率统计框架,需要借助已有的工具实现这点。

解决问题

提升开发自测质量和提测质量,避免case覆盖不全的问题,同时有直观的统计指标衡量。

预期效果

分3个阶段:

第一阶段:基本可用版本,能够采集UI层和API层的代码覆盖率,覆盖率采集过程对前端开发透明,无需开发业务以外的开发成本。能够打开浏览器页面直观查看各个代码覆盖率。

第二阶段:丰富统计数据,在第一阶段基础上,能够记录不同分支甚至不同版本历史的覆盖率,能够借助gitlab只查看增量文件的覆盖率。

第三阶段:打通QA平台,在第二阶段基础上,接入QA已有平台或自建平台,在第三方页面查看数据;形成一套覆盖率采集方案。

使用场景

本地、特性分支上使用

技术选型

覆盖率采集建立在代码插桩)基础上。前端生态里比较成熟的工具是istanbuljs,该库的0.x.x版本位于istanbul库。关于该库的科普介绍,可以阅读ruanyifeng的科普文

istanbul提供两种插桩方式:编译时运行时

  • 编译时
  • 运行时,需要借助istanbul-middleware中间件的帮助
    • im.hookLoader,适用于服务端文件的动态插桩,方法利用istanbul-lib-hook中的hookRequire方法,hook被require引入的js文件,返回插桩后的js文件。因此需要在业务代码require前引入
    • im.createClientHandler,用于客户端js文件的动态插桩,它会把指定根路径下的js文件请求拦截,返回插桩后的代码。

在后台项目中,服务端代码使用.ts书写,本地使用ts-node启动,boe和线上使用tsc编译后的js文件启动。前端代码使用.ts书写,本地使用webpack + babel预编译成js。结合这个场景看,在node侧使用hookLoader,在UI测使用babel-plugin-istanbul插件更合适。

总体来讲,工作分两步:代码插桩可视化

代码插桩

规划里一切功能的基础和本质都来自代码插桩。

client端

client端可以直接使用目前仍良好维护的babel-plugin-istanbul插件。在.babelrc中引入相关配置:

1
2
3
4
5
6
7
{
"env": {
"test": {
"plugins": ["istanbul"]
}
}
}

仅在test环境下开启该插件。可以用cross-env这样的工具设置NODE_ENV=test

1
2
3
4
5
{
"scripts": {
"test": "cross-env NODE_ENV=test npm run start"
}
}

插件还有includeexcludeuseInlineSourceMaps这样的配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"env": {
"test": {
"plugins": [
["istanbul", {
"useInlineSourceMaps": false,
"exclude": [
"**/*.spec.js"
]
}]
]
}
}
}

插桩后的业务代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
var cov_ac7rkuoyv = function () {
var path = "/Users/test/shenlvmeng/nyc-demo/src/App.js";
var hash = "7dec600464f484deef063d183319f809a7c25687";
var global = new Function("return this")();
var gcv = "__coverage__";
var coverageData = {
path: "/Users/shenlvmeng/nyc-demo/src/App.js",
statementMap: {
"0": {
start: {
line: 8,
column: 2
},
end: {
line: 14,
column: 9
}
}
// ...
},
fnMap: {
"0": {
name: "App",
decl: {
start: {
line: 7,
column: 9
},
end: {
line: 7,
column: 12
}
},
loc: {
start: {
line: 7,
column: 15
},
end: {
line: 33,
column: 1
}
},
line: 7
},
// ...
},
branchMap: {},
s: {
"0": 0,
// ...
},
f: {
"0": 0,
// ...
},
b: {},
_coverageSchema: "43e27e138ebf9cfc5966b082cf9a028302ed4184",
hash: "7dec600464f484deef063d183319f809a7c25687"
};
var coverage = global[gcv] || (global[gcv] = {});
if (coverage[path] && coverage[path].hash === hash) {
return coverage[path];
}
return coverage[path] = coverageData;
}();
var _jsxFileName = "/Users/test/shenlvmeng/nyc-demo/src/App.js";
function App() {
cov_ac7rkuoyv.f[0]++;
cov_ac7rkuoyv.s[0]++;
Object(react__WEBPACK_IMPORTED_MODULE_0__["useEffect"])(() => {
cov_ac7rkuoyv.f[1]++;
cov_ac7rkuoyv.s[1]++;
(async () => {
cov_ac7rkuoyv.f[2]++;
cov_ac7rkuoyv.s[2]++;
console.log(window.__coverage__);
cov_ac7rkuoyv.s[3]++;
axios__WEBPACK_IMPORTED_MODULE_1___default.a.defaults.headers.post['Access-Control-Allow-Origin'] = '*';
cov_ac7rkuoyv.s[4]++;
axios__WEBPACK_IMPORTED_MODULE_1___default.a.post('http://localhost:4000/coverage/client', window.__coverage__);
})();
}, []);
cov_ac7rkuoyv.s[5]++;
return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", {
className: "App",
__source: {
fileName: _jsxFileName,
lineNumber: 16
},
__self: this
}, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("header", {
className: "App-header",
__source: {
fileName: _jsxFileName,
lineNumber: 17
},
__self: this
}, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", {
src: _logo_svg__WEBPACK_IMPORTED_MODULE_2___default.a,
className: "App-logo",
alt: "logo",
__source: {
fileName: _jsxFileName,
lineNumber: 18
},
__self: this
}), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("p", {
__source: {
fileName: _jsxFileName,
lineNumber: 19
},
__self: this
}, "Edit ", react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("code", {
__source: {
fileName: _jsxFileName,
lineNumber: 20
},
__self: this
}, "src/App.js"), " and save to reload."), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("a", {
className: "App-link",
href: "https://reactjs.org",
target: "_blank",
rel: "noopener noreferrer",
__source: {
fileName: _jsxFileName,
lineNumber: 22
},
__self: this
}, "Learn React")));
}

可以看到针对每个文件,插桩后的代码新建了一个函数,用于更新这个文件的覆盖率信息。在业务代码执行时,会不断更新对应的计数器。前端代码run起来后,可以通过window.__coverage直接得到当前项目的覆盖率情况。

覆盖率计算原理

拿到代码字符串后,使用AST解析库解析出语法树,在对应树节点插入覆盖率语句,之后将语法树转成插桩后的代码。这个过程在0.x.x版本的实现可以参加这里

0.x.x版本的instanbul使用esprimaescodegen。在最新的istanbuljs的istanbul-lib-instrument中使用babel相关的包完成解析、生成等功能,但插桩思路不变。

node端

node端使用istanbul-middleware中间件的hookLoader完成动态插桩。该中间件为instanbul作者所写,所依赖的istanbul版本是目前已经不再维护的0.x.x版本,同时中间件也是基于express所写,年代较老。中间件的设计和我们的场景有些距离,因此需要在其基础上进行改造。

在预期场景下,我们主要使用im.hookLoaderim.createHandler两个方法。

im.hookLoader

im.hookLoader只是在instanbul hook的基础上做的简单封装。看文档可以发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hookRequire(matcher, transformer, options) {
options = options || {};
var fn = transformFn(matcher, transformer, options.verbose),
postLoadHook = options.postLoadHook &&
typeof options.postLoadHook === 'function' ? options.postLoadHook : null;
Module._extensions['.js'] = function (module, filename) {
var ret = fn(fs.readFileSync(filename, 'utf8'), filename);
if (ret.changed) {
module._compile(ret.code, filename);
} else {
originalLoader(module, filename);
}
if (postLoadHook) {
postLoadHook(filename);
}
};
}

hook require是利用CommonJS的模块加载规范实现的。这里介绍下模块加载流程相关的背景。

模块加载流程

require和module来自nodejs的Modules模块。无需显式引入(原因下面会提)即可使用里面的module, require, exports的关键字。在require一个包时,分为了解析、加载、封装、求值、缓存几步。

  1. 解析一步交给module.require(modulename)方法实现,该方法调用静态方法Module._load加载模块。
  2. 加载时首先调用Module._resolveFilename解析路径,解析过程大致分为粗筛和精确定位
    1. 粗筛,原生模块则直接返回模块名,否则从当前目录逐步向上寻找node_modules目录下的模块文件夹
    2. 精确定位,首先在上面的目录下寻找有无没有拓展名的同名文件,再寻找以jsjsonnode拓展名结尾的文件;再寻找同名目录下package.jsonmain字段指定的路径,最后寻找同名目录下的index.js
  3. 得到路径后,先试图从Module._cache中寻找有无模块缓存,若没有,则新建模块对象并缓存,之后调用module.load()方法加载该模块
  4. node默认只能load以js, json, node结尾的文件,除此之外均视为.js文件
1
2
3
4
5
6
7
8
9
Module.prototype.load = function(filename) {
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));
var extension = path.extname(filename) || '.js';
if (!Module._extensions[extension]) extension = '.js';
Module._extensions[extension](this, filename);
this.loaded = true;
};
  1. Module._extensions键值对默认只包含对上述三种文件的处理。其中json和node文件较简单
    • json,读取文件 => JSON.parse => 注入到module.exports
    • node,调用process.dlopen加载
    • js,调用module._compile()处理文件内容
  2. module._compile执行js文件编译,编译前调用Module.wrap方法将模块封装在函数内,这也是modulerequireexports__filename__dirname可以直接在模块内使用,且模块间的modulerequireexports不相互干扰的原因。
1
2
3
4
5
6
7
Module.wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
Module.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
  1. wrap后,调用vm.runInThisContext将字符串转为可执行的js函数。最后一句执行封装的函数,注入当前module的相关信息到模块中
1
2
3
4
5
6
7
8
9
10
11
12
13
Module.prototype._compile = function(content, filename) {
// ...
var wrapper = Module.wrap(content);
var compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true
});
// ...
// 执行
result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
return result;
}

实际上,可以在REPL中输入require.extensions看到nodejs支持的3种文件拓展。而hookRequire所做的就是hook了加载.js的步骤,在交由module._compile处理前,前进行了插桩处理。

注:ts-node能够import.ts文件也是因为在module._extendsion中添加了.ts的处理方法

综上,在本地测试时,由于通过ts-node启动,im.hookLoader只会hook.js文件(0.x.x版本),会导致没有服务端代码覆盖率,上线前tsc后,就可以顺利注入,从global.__coverage__中拿到覆盖率数据。

数据采集

服务端覆盖率数据采集借助上述的im.hookLoader就已完成。前端页面的代码覆盖率需要自行周期性上报。im.createHandler提供的API可以接收前端覆盖率,该方法创建的路由回调会调用utils.mergeFileCoverage最终将增量覆盖率数据累加在global.__coverage__中。

可视化

可视化需要借助istanbul(0.x版本)本身提供的相关API,又或者直接更方便地使用im.createHandler。其方便封装了一些istanbul的API,实现的简单的可视化功能,更多介绍可以直接参考istanbul-middleware使用文档。

im.createHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function createHandler(opts) {
// ...
var app = express();
// ...
//send static file for /asset/asset-name
app.use('/asset', express.static(ASSETS_DIR));
app.use('/asset', express.static(path.join(ASSETS_DIR, 'vendor')));
app.use(bodyParser.urlencoded(urlOptions));
app.use(bodyParser.json(jsonOptions));
app.get('/', function (req, res) {
// ...
core.render(null, res, origUrl);
});
// ...
}

从上面的使用上看,im.createHandler的使用是和express绑定的,并不适配koa的风格,要想使用在非express服务端场景里,或者增加额外功能,就需要重构这个方法。不过,由于一些API的使用从0.x.x版本迁移到新的monorepo的版本并不平滑,且新版本API暂无文档。所以尽管instanbul已经不再更新,对istanbul-middleware中间件的重构还只能保持对旧版本istanbul的依赖。

重构设计

为了兼容koa环境,从istanbul-middleware库fork新版本istanbul-koa-middleware,使用ts重写。去掉无用的代码,重点需要改造的就是createKoaHandler方法。

为减少istanbul-koa-middleware使用方的依赖,考虑使用类似istanbul-middleware的形式,在createKoaHandler内部定义koa应用,使用方只需引入koa-mount和istanbul-koa-middleware即可在任意路由上挂载覆盖率可视化相关子路由。

除此之外:

  • 使用koa-static替代express.static
  • 使用koa-mount替代app.use('/some/path', someMiddleware)
  • 使用koa-router替代app.getapp.post
  • 定义WebFramework接口,掩盖对回调中res使用方式的不同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface WebFramework {
setHeader: (key: string, value: string) => void;
status: (code: number) => void;
write: (content: any) => void;
end: (content?: string) => void;
}
function genWebFrameworkCtxFromKoaCtx(ctx: Koa.ParameterizedContext): WebFramework {
return {
setHeader(key, value) {
ctx.set(key, value);
},
status(code) {
ctx.status = code;
},
write(content) {
ctx.res.write(content);
},
end(content) {
ctx.res.end(content);
}
};
}

这样,重构后的istanbul-koa-middleware就可以帮我们实现最简单的覆盖数据可视化了。后续的新增功能,也都建立在对createHandler函数的改造上。

已知问题

在上面工作完成后,第一阶段目标基本已经实现。

效果图1
效果图2

但是,还有很多可以优化的地方,这也是后续阶段需要解决的问题:

  • 服务端代码如果没有正确设置source-map,覆盖率展示的文件是tsc之后的js文件,可读性差
  • 需要区分环境,在特定环境下才对代码文件插桩
  • 本地环境下可以访问到原始的ts文件,上传测试环境后,由于不会打包上传源文件,覆盖率报告将看不了原始ts文件的覆盖详情
  • 覆盖率报告功能太简单,不能以分支、版本、仓库等空间维度或以历史信息、趋势等时间维度查看
    ,也没有diff内容覆盖率功能
  • 出现了一些意义不大的文件,需要剔除

更多