目前猫眼已经完成了各类前端项目的代码覆盖率工具建设,并在部分项目中实践。本文主要介绍前端代码覆盖率工具的建设思路和实践过程,希望能给相关同学以借鉴,欢迎大家一起交流。
背景
在猫眼,前端项目种类较多,有 PC 端、移动端、小程序、客户端(MRN),同时业务迭代速度快,QA 和 RD 在进行测试和自测时没有量化的指标。为了提供一种自动的方式量化测试结果,促使测试改进,开始着手开发前端代码覆盖率工具。
目标是建成一个支持多端、支持增量数据统计的代码覆盖率工具。
工具结构
工具分为 5 部分,分别是代码插桩、覆盖率数据收集上报、覆盖率数据存储、覆盖率报告生成、消息通知。
-
代码插桩:根据项目不同选择了不同的插桩工具,其中 PC 端、移动端、客户端(MRN)选择了
nyc
,小程序端使用的是针对小程序插桩二次开发的istanbul-lib-instrument
,下文将详细介绍。 -
覆盖率数据收集上报:coverage-reporter 是一个 npm 包,嵌入到前端项目中,当用户访问到目标页面时会上报数据。小程序端上报功能是集成在猫眼 QA 工具中,本文将不做介绍。
-
覆盖率数据存储:coverage-admin 是一个后端服务,提供上报数据接口。S3 是一个存储服务,这里用来存放上报的数据。
-
覆盖率报告生成:coverage-report 是一个 Node 项目,使用了
istanbul-lib-coverage
、istanbul-lib-report
、istanbul-reporters
等工具包生成报告,由 Jenkins 通过脚本调用。 -
消息通知:coverage-report 生成报告后,调用猫眼内部消息平台接口,给用户发送覆盖率相关消息。
下面将针对各部分进行详细介绍。
代码插桩
想实现代码覆盖率,首先需要对代码进行插桩。目前业内流行的插桩工具有 babel-plugin-istanbul
和nyc
,结合猫眼的项目特点并考虑方案的通用性,最终,我们选择了nyc
作为插桩工具。
nyc
是利用了istanbul-lib-instrument
完成代码插桩,istanbul-lib-instrument
利用 babel 的能力,对源代码进行“拆解”,将代码“拆解”为抽象语法树,并对抽象语法树进行重新编辑,在必要的位置增加探针代码的抽象语法树,最后再利用 babel 将抽象语法树转化为代码,进而完成代码插桩。
整个插桩流程是集成在发布平台中,发布平台提供了执行自定义脚本的功能,可以通过传递不同的环境变量控制脚本的执行流程。工具的插桩分为两种插桩:
- 全量插桩:针对目标文件夹下的全部文件进行插桩。
- 增量插桩:针对目标文件夹下新需求开发导致变动的文件进行插桩。
提供两种插桩方式是为了应对不一样的场景:全量插桩适合进行功能回归时,对整个项目或项目的部分功能进行测试,此时测试不再关注哪部分代码是老功能添加的,哪部分代码是新功能添加的,更关注的是整个项目提供的功能是否正常;增量插桩适合进行需求测试时,此时测试更关注新需求变动的代码。
小程序端插桩
猫眼小程序用户破亿,保障其质量也是重中之重。在使用nyc
对小程序代码进行插桩时却遇到了问题。
使用nyc
插桩,插桩后代码与原代码相比,有如下两部分变化:
- 文件前部的覆盖率数据内容。
- 文件中插入的大量探针代码。
这导致小程序的代码体积变大。因微信对小程序分包体积的限制(2MB),使用nyc
插桩的包可能无法正常打包预览。
拿一个简单的加法代码举例,插桩后结果如下图:
正是这两部分增加的内容,导致代码体积变大,如果可以减少这两部分内容,就有希望将包体积控制在 2MB 之内,为此我们对istanbul-lib-instrument
做了如下改造:
- 不再在代码中插入覆盖率数据。
- 修改记录代码执行次数的逻辑。
具体体现如下:
① 处圈选的是去掉覆盖率数据内容;② ③ 圈选的是修改后的计数逻辑。
在插桩过程中,修改了 codeVisitor 的逻辑,原 codeVisitor 在遇到语句、方法、分支等内容时会将内容的 location 信息记录到覆盖率数据中,并为对应内容增加一个计数器。修改后的逻辑则去掉了这部分内容,如下:
// istanbul-lib-instrument/src/source-coverage.js
newStatement(loc) {
const s = this.meta.last.s;
this.data.statementMap[s] = cloneLocation(loc);
this.data.s[s] = 0;
this.meta.last.s += 1;
return s;
}
// 修改后
newStatementWithoutMap(loc) {
const s = this.meta.last.s;
this.meta.last.s += 1;
return s;
}
复制代码
原插桩逻辑生成的探针代码执行的方式是使用对象直接执行自加,这种方式会在代码文件中重复出现覆盖率对象的名称,如cov_70rppf3jl().s[0]++
、cov_70rppf3jl().s[1]++
,为了最大限度的减少插桩的副作用,将探针代码改为调用统一的方法,同时尽可能减少调用方法时编写代码的长度,如下:
// 原插桩逻辑插桩后代码节选
cov_70rppf3jl();
cov_70rppf3jl().s[0]++;
add = (p1, p2) => {
cov_70rppf3jl().f[0]++;
cov_70rppf3jl().s[1]++;
return p1 + p2;
};
// 新插桩逻辑插桩后代码节选
cov_70rppf3jl();
cadd("s", 0);
add = (p1, p2) => {
cadd("f", 0);
cadd("s", 1);
return p1 + p2;
};
复制代码
cadd 方法是抽取出来的探针方法,详情可查看上面修改插桩逻辑后的插桩结果图。
数据上报
前端项目产生的覆盖率数据存储在各端中,想收集到覆盖率数据还需要各端主动上报,为此开发了一个工具包(下称 reporter)。
reporter 为了保证数据不丢失,在周期性上报的基础上,增加了页面刷新和关闭事件监听,当事件触发时上报一次数据;还另外支持了手动上报,手动上报需要依靠调试控制台,是把 reporter 对象赋值给 window,当进行测试时可以通过控制台调用手动上报。
同时为了避免过多重复的覆盖率数据,每个 reporter 在被创建时生成一个 8 位 id,每个 reporter 每小时只保留 1 份数据。
数据存储
前端项目产生的覆盖率数据是一个 json 对象,json 对象不适合存储在传统的数据库中。我们选择 S3 存储覆盖率数据,将覆盖率数据以文件的形式存储。
当 reporter 上报数据时,同时上报被测试项目的项目名称、测试分支、commitHash 值。存储到 S3 中时,以项目名称/测试分支/commitHash 为路径。在生成报告时,可以根据需要拉取不同维度的数据。
报告生成
覆盖率报告生成使用了 istanbul 提供的系列工具包,流程图如下:
增量报告
进行需求测试时,QA 同学更关注的是被测试需求新增代码的覆盖情况,在前文中已经介绍过增量插桩,增量插桩是文件级别的增量,当文件被改动过就会被识别。而增量代码是代码级别的增量,可以精确到行,在每一个变动的文件中都存在着代码的变动,代码的变动类型有“新增”、“改变”、“删除”。这里,我们将“新增”和“改变”的代码认为是增量代码。
工具的增量报告目前只支持了增量语句,增量语句的计算公式如下:
增量语句覆盖率 = (当前已覆盖的新增语句数)/(新增语句数) * 100%
公式中涉及到两个指标:1.当前已覆盖的新增语句数,2.新增语句数。
工具利用了代码 diff 来分析增量代码,通过代码 diff,可以得到两个分支间变动文件中每行的情况,通过行的新增情况,可以进一步分析语句的新增情况。
新增语句判定规则:如果一个语句中包含新增的行,则认为它是新增的语句。
已覆盖新增语句判定规则:如果一个语句是新增语句,并且被覆盖了,则认为它是已覆盖新增语句。
// 伪代码
// data 是 coverageMap 对象,具有 statementMap、branchMap、functionMap、s、b、f
// lines 是文件diff结果中新增的行号数组
const { statementMap, s } = data;
// 获取新增语句的Map
Object.entries(statementMap).forEach(([k, location]) => {
const { start, end } = location;
const { line: startLine } = start;
const { line: endLine } = end;
for (let i = 0; i <= lines.length; i++) {
// 判断这个语句中是否有新增的行
if (lines[i] >= startLine && lines[i] <= endLine) {
// 如果有则装进incrementStatementMap
incrementStatementMap[k] = location;
break;
}
}
});
// 获取新增语句的index
const incrementStatementNum = Object.keys(incrementStatementMap);
// 获取被覆盖的新增语句
Object.entries(s).forEach(([k, v]) => {
// 判断语句是否被覆盖
if (v > 0) {
// 判断被覆盖的语句是否属于新增语句
if (incrementStatementNum.indexOf(k) > -1) {
incrementStatementMap[k].covered = true;
incrementCoveredS[k] = v;
}
}
});
// 新增语句的Map,包含location信息
data.incrementStatementMap = incrementStatementMap;
// 新增被覆盖语句计数器
data.incrementCoveredS = incrementCoveredS;
复制代码
经过上述代码处理后,coverageMap 对象具有了两个新的属性:incrementStatementMap 和 incrementCoverdS。后续在 istanbul-reports
生成报告时通过解析这两个新增的属性,可以展示报告中行的新增情况和覆盖情况。
// 伪代码
// istanbul-reports/lib/html/annotator.js
// 解析新增语句
// fileCoverage:coverageMap在生成报告流程中的对象,子对象具有statementMap、branchMap、functionMap、s、b、f、incrementStatementMap、incrementCoveredS
// structuredText:源代码在生成报告流程中的对象,是一个数组,元素是代码每行的内容
function annotateIncrementStatements(fileCoverage, structuredText) {
const { data } = fileCoverage;
if (data.incrementStatementMap && data.incrementCoveredS) {
const { incrementStatementMap } = data;
Object.entries(incrementStatementMap).forEach(([k, location]) => {
const { start, end, covered } = location;
const { line: startLine } = start;
const { line: endLine } = end;
structuredText.forEach(text => {
// 前端项目中一个语句可能包括多行,这里让报告更清晰,将新增语句中的每行都标记上
if (text.line <= endLine && text.line >= startLine) {
// 标记为新增
text.addStatement = 'yes';
// 计算是第几个新增语句
text.addStatementNum = Object.keys(incrementStatementMap).indexOf(k) + 1;
// 判断是否覆盖
if (covered) {
text.addStatementCovered = 'yes';
} else {
text.addStatementCovered = 'no';
}
}
});
})
}
}
// istanbul-reports/lib/html/index.js
function detailTemplate(data) {
// ...
const statementAdd = line => {
// line.added:diff结果中新增的行,line.added会被设置为yes
if (line.addStatement === 'yes' && line.added === 'yes') {
if (line.addStatementCovered === 'yes') {
return `<span class="cline-any cline-addStatement-yes">已覆盖新增语句${line.addStatementNum}</span>`;
} else if (line.addStatementCovered === 'no')
return `<span class="cline-any cline-addStatement">未覆盖新增语句${line.addStatementNum}</span>`;
}
}
// ...
}
复制代码
增量报告中某文件的局部效果如图:
增量报告全局效果如图:
数据合并
在实际的需求测试中,测试结束后期望有一个最终的覆盖率报告,能记录整个测试过程对代码的覆盖程度。而一个服务被测试时,有可能因为修复 Bug 而多次发布服务,每个版本的代码产生的覆盖率数据又不同,不同的覆盖率数据不能直接合并,istanbul-lib-coverage
提供了相同版本覆盖率数据合并的方法:
// istanbul-lib-coverage/lib/file-coverage.js
merge(other) {
if (other.all === true) {
return;
}
if (this.all === true) {
this.data = other.data;
return;
}
Object.entries(other.s).forEach(([k, v]) => {
this.data.s[k] += v;
});
Object.entries(other.f).forEach(([k, v]) => {
this.data.f[k] += v;
});
Object.entries(other.b).forEach(([k, v]) => {
let i;
const retArray = this.data.b[k];
if (!retArray) {
this.data.b[k] = v;
return;
}
for (i = 0; i < retArray.length; i += 1) {
retArray[i] += v[i];
}
});
}
复制代码
针对不同版本的覆盖率数据,直接加和的策略将不能使用。为了获得一个被测试分支的合并覆盖率结果,需要对不同版本的覆盖率数据进行合并。
合并策略:相同版本的覆盖率数据结果通过istanbul-lib-coverage
提供的合并方法合并,不同版本的覆盖率数据合并时,以最新版本代码产生的覆盖率数据为基准,在其基础上合并未变动文件的覆盖率数据,舍弃变动文件的覆盖率数据。
执行不同版本覆盖率数据合并时,首先判断文件是否改动过,目前工具是通过比较两个版本序列化后的 statementMap、branchMap、functionMap 是否相等来确定的,如果各类 Map 都没有变化,这表明整个文件各语句、分支、方法的位置信息没有变化,在这里认为文件未变动。
消息通知
报告是由 Jenkins 触发生成的,生成的报告直接上传到 S3 中保存,为了让用户了解测试的结果,在报告生成后,coverage-report(报告工具)对覆盖率数据进行了一个简单的分析,对覆盖率数据进行汇总。当用户选择生成全量报告时,会产生如下格式的消息体:
[测试项目] 全量代码覆盖率报告已更新
仓库地址:[git仓库地址]
测试分支:[当前测试分支]
测试时间:2021/06/05-11:00 ~ 2021/06/07-23:59
报告地址:#338 点击查看
报告时间:6/7/2021, 20:30:10
=============================== Coverage summary ===============================
Statements : 36.36% ( 1124/3091 )
Branches : 21.39% ( 203/949 )
Functions : 31.6% ( 243/769 )
Lines : 36.71% ( 1117/3043 )
================================================================================
复制代码
当用户选择生成增量报告时,会产生如下格式的消息体:
[测试项目] 增量代码覆盖率报告已更新
仓库地址:[git仓库地址]
原始分支:[原始分支] ---> 当前测试分支:[当前测试分支]
测试时间:2021/06/05-11:00 ~ 2021/06/07-23:59
报告地址:#339 点击查看
报告时间:6/7/2021, 20:32:30
=============================== Coverage summary ===============================
Statements : 25.02% ( 563/2250 )
Branches : 29.29% ( 169/577 )
Functions : 45.7% ( 266/582 )
Lines : 25.19% ( 559/2219 )
IncrementStatements : 21.47% ( 292/1360 )
================================================================================
复制代码
后续规划
目前猫眼内部已有 7 个项目接入前端代码覆盖率工具,种类包括PC端、移动端、客户端、小程序。用户在使用中也提出一些问题,如使用时配置较繁琐、无更详细的数据分析、无数据的趋势变化等。覆盖率报告由 Jenkins 触发生成,工作流受限于 Jenkins,为了更快的解析数据生成报告,无法再多做其他操作。
针对前端代码覆盖率工具,还有几个方向仍需努力:
- 增量报告增加更多指标,如增量分支覆盖率、增量方法覆盖率等。
- 更精细的多版本覆盖率数据合并策略。
- 数据与报告统一管理,全面展示各测试阶段代码覆盖率情况。
后续我们将针对代码覆盖率这一指标开发代码覆盖率平台,统一管理前后端项目的代码覆盖率,从多维度分析数据,产出更有价值的报告,为猫眼的产品护航。
招聘信息
猫眼质量团队持续招聘中,欢迎感兴趣的同学投递简历:戳这里