作者简介
wr_zhang25,携程资深前端开发工程师,关注前端代码覆盖率、JavaScript开源方向。
Liang, 携程资深研发经理,质量专家,专注质量工程领域。
一、背景
istanbuljs 是一款优秀的JavaScript代码覆盖率工具,主要用于单元测试的代码覆盖率检测和生成本地覆盖率报告。然而,随着现代前端技术和UI自动化测试的发展,对端到端测试的代码覆盖率检测需求逐渐增加,istanbuljs提供的功能显得捉襟见肘。
在携程内部JavaScript代码覆盖率使用的是gitlab内置的coverage上报,也是只支持单元测试的覆盖率收集和概览数据展示。随着携程的前端技术日益精进,我们有了自己的前端流量录制平台,并且部署了相当大规模的模拟器集群进行UI自动化(flybirds)回放。这种场景下,需要对端到端测试的代码覆盖率进行收集和展示,以便开发同学更好的了解到自己的代码质量。
传统的istanbuljs提供的功能已经无法满足我们的需求。我们需要处理UI自动化过程中来自前端高并发的覆盖率上报,实时的覆盖率聚合,以及覆盖率数据的聚合展示。因此,我们在Istanbuljs的基础上开发了Canyon,解决端到端测试覆盖率难收集的问题。
目前,携程的多个部门已经开始使用Canyon,并在持续集成流水线构建阶段插入探针代码,在UI自动化测试阶段收集和上报覆盖率数据。服务端实时生成详尽的覆盖率报告,为UI自动化测试用例提供全面的覆盖率数据指标。
二、介绍
Canyon 通过简单的 Babel 插件配置即可实现代码插装、覆盖率上报和实时报告生成。其技术栈完全基于 JavaScript,只需 Node.js 环境即可运行,部署方便,适用于云原生环境的部署(如 Docker、Kubernetes)。
应用的架构设计适用于处理高频、大规模的覆盖率数据上报,能够应对 UI 自动化测试中的各种场景。同时,Canyon 与现有的 CI/CD 工具(如 GitLab CI、Jenkins)无缝集成,使用户能够轻松地在持续集成流水线中使用。
架构图如下:
下面会根据以下几个部分来介绍 Canyon 的主要功能:
- 代码覆盖率
- 代码插桩
- 测试与上报
- 覆盖率聚合
- 覆盖率报告
- 变更代码覆盖率
- react native 覆盖率收集方案
- 覆盖率提升优先级列表
三、代码覆盖率
随着编写更多的end-to-end测试case,你会发现有一些疑问,我需要写更多的测试用例吗?究竟还有哪些代码没测到?用例会不会重复了?这个时候代码覆盖率就派上用场了,它的原理是在代码执行前将代码探针插入到源代码中(其实就是上下文加计数器),这样每当case执行的时候就可以触发其中的计数器。
在代码中插入代码探针的步骤称为代码插桩(instrument)。插桩前的代码:
// add.js
function add(a, b) {
return a + b
}
module.exports = { add }
插桩过程是对代码解析以查找所有函数、语句和分支,然后将计数器插入代码中。对于上面的代码,插桩完成后:
// 这个对象用于计算每个函数和每个语句被执行的次数
const c = (window.__coverage__ = {
// "f" 记录每个函数被调用的次数
f: [0],
// "s" 记录每个语句被调用的次数
// 我们有3个语句,它们都从0开始
s: [0, 0, 0],
})
// 第一个语句定义了函数
c.s[0]++
function add(a, b) {
// 函数被调用后是第二个语句
c.f[0]++
c.s[1]++
return a + b
}
// 第三个语句即将被调用
c.s[2]++
module.exports = { add }
我们希望确保文件中的每个语句和函数add.js都已被我们的测试至少执行一次。因此我们编写一个测试:
// add.cy.js
const { add } = require('./add')
it('adds numbers', () => {
expect(add(2, 3)).to.equal(5)
})
当测试调用时add(2, 3),执行“add”函数内的计数器递增,覆盖范围对象变为:
{
f: [1],
s: [1, 1, 1]
}
这个测试用例覆盖率达到了100%,每个函数和每个语句都至少执行了一次。但是在实际应用中,要达到100%的代码覆盖率需要多次测试。
这是覆盖率的基本介绍,有了这个前置知识,方便大家理解下面的内容。
四、代码插桩(instrumenting-code)
代码覆盖率最重要的一环就是代码插桩
istanbuljs 是久经沙场的js代码插桩黄金标准。Canyon主要为端到端测试提供解决方案,经过大量的实验验证,现代化前端工程的覆盖率插桩必须要编译时插桩。具体原因是istanbuljs提供的nyc插桩工具只能对原生js进行插桩,然而前端模版语法层出不穷,例如ts、tsx、vue,虽然nyc也可以插桩,但是结构实践证明直接插桩的覆盖率效果不尽人意,无法精确到该插桩到的函数、语句、分支。
幸运的是经过调研,我们发现了babel-plugin-istanbul、vite-plugin-istanbul(experimental)、swc-plugin-coverage-instrument(experimental)。等类型工程的插桩解决方案。这些方案无一例外都是在前端工程编译阶段在将代码分析成ast抽象语法树的时候在适当时机进行插桩方法调用,更精确的插桩到的函数、语句、分支。
适用的工程类型:
工程类型 | 方案 |
vanilla javascript | nyc |
babel | babel-plugin-istanbul |
vite | vite-plugin-istanbul (experimental) |
swc | swc-plugin-coverage-instrument (experimental) |
用户可以根据自己的工程类型选择合适的插桩方案,只需要在工程中安装对应的插件,然后就会在编译时自动插桩。
以babel.config.js为例:
module.exports = {
plugins: [
[
'babel-plugin-istanbul',
{
exclude: ['**/*.spec.js', '**/*.spec.ts', '**/*.spec.tsx', '**/*.spec.jsx'],
},
],
],
};
插桩完成后,代码中会插入一些代码探针,这些代码探针会在运行时收集覆盖率数据,然后上报到Canyon服务端。
检查是否插桩成功,可以在编译后的产物中搜索__coverage__,如果有则说明插桩成功。
为了紧密关联插桩代码的源代码,我们适配了各种provider,将环境变量发送到Canyon服务端,兑换到reportID,方便覆盖率数据聚合计算完成后的覆盖率源文件的关联展示。
我们还提供了babel-plugin-canyon的babel插件,可以在各种流水线内(aws,gitlab ci)读取环境变量(branch、sha),以供后续覆盖率数据与对应的gitlab源代码关联。
babel.config.js
module.exports = {
plugins: [
[
'babel-plugin-canyon',
{
provider: 'gitlab',
branch: process.env.CI_COMMIT_REF_NAME,
sha: process.env.CI_COMMIT_SHA,
},
],
],
};
支持的提供商:
- Azure Pipelines
- CircleCI
- Drone
- Github Actions
- GitLab CI
- Jenkins
- Travis CI
需要特别注意的是,代码探针的插桩会在构建产物上下文加上代码探针,会是代码整体产物增大30%,建议不要上生产环境。
五、测试与上报
当插桩完成发布到测试环境后,我们就可以进行测试了。拿playwright举例,对于插桩成功的前端应用站点,window对象上面都会挂载__coverage__和__canyon__对象,我们需要在playwright测试过程中收集并上报这些数据到canyon的服务端。
playwright示例:
const {chromium} = require('playwright');
const main = async () => {
const browser = await chromium.launch()
const page = await browser.newPage();
// 进入被测页面
await page.goto('http://test.com')
// 执行测试用例
// 用例1
await page.click('button')
// 用例2
await page.fill('input', 'test')
// 用例3
await page.click('text=submit')
const coverage = await page.evaluate(`window.__coverage__`)
// 收集上报覆盖率
upload(coverage)
browser.close()
}
main()
携程内部有自己的UI自动化平台 flybirds,我们在flybirds内部集成了Canyon覆盖率数据的收集和上报。真实的浏览器UI自动化测试的覆盖率收集场景较为复杂,主要体现在多页面(MPA)的覆盖率收集时机不确定性。
单页面(SPA)与多页面(MPA)
当测试用例执行完成后,对于单页面应用(SPA)或者多页面应用而言,上报步骤是将页面window对象上的__coverage__对象上报到Canyon服务端,对于单页面应用来说,相对来说比较简单,在所有测试内容都在单页面应用内,覆盖率数据会常驻在window对象中,对于多页面应用而言,路由的跳转会导致window对象的重制,丢失coverage对象。所以这个时机是至关重要的,经过大量实践验证,我们找到了浏览器的onvisiblechange方法。
- visibilitychange
在浏览器可见性改变的时候上报覆盖率数据,值得一提的是,对于visibilitychange这种可能会导致重复数据上报,但是对于覆盖率统计来说,未执行到的代码多次合并来说不会影响覆盖率的具体指标数据统计。
- fetchLater
Chrome 浏览器正在积极引入一个革命性的 JavaScript API——fetchLater()。这个全新的 API 旨在彻底简化关闭页面时的数据发送过程,确保即使在页面关闭后或用户离开的情况下,请求也能在未来某个时刻被安全、可靠地发出。
这个API的推出时令人振奋的,可以很好的解决多页面(MPA)收集难的问题,只需要在浏览器关闭时收集。
注:fetchLater() 已在 Chrome 中提供,用于在版本 121(2024 年 1 月发布)开始的原始试验中供真实用户测试,该试验将持续到 Chrome 126(2024 年 7 月)。
六、聚合
覆盖率数据的来源是同一版本的代码,覆盖率数据是可以聚合的,Canyon内部使用reportID来关联测试用例和细分聚合维度。这样做可以让海量的覆盖率数据聚合成有限个,即Case的数量。
/**
* 合并两个相同文件的文件覆盖对象实例,确保执行计数正确。
*
* @method mergeFileCoverage
* @static
* @param {Object} first 给定文件的第一个文件覆盖对象
* @param {Object} second 相同文件的第二个文件覆盖对象
* @return {Object} 合并后的结果对象。请注意,输入对象不会被修改。
*/
function mergeFileCoverage(first, second) {
const ret = JSON.parse(JSON.stringify(first));
delete ret.l; // 移除派生信息
Object.keys(second.s).forEach(function (k) {
ret.s[k] += second.s[k];
});
Object.keys(second.f).forEach(function (k) {
ret.f[k] += second.f[k];
});
Object.keys(second.b).forEach(function (k) {
const retArray = ret.b[k];
const secondArray = second.b[k];
for (let i = 0; i < retArray.length; i += 1) {
retArray[i] += secondArray[i];
}
});
return ret;
}
端到端测试的覆盖率数据特点之一是单体数据体积大,在项目整体插桩的情况下相当于整体源代码体积的30%。携程Trip.com flight站点的预定页UI自动化case上报次数每次可达2000次,每次10M数据,这样的数据量对于Canyon服务端来说是一个巨大的挑战。
对于单条数据大且高频次的数据上报场景,很难做到实时数据聚合计算。Canyon采用消息队列的形式来消费数据,并且设计成无状态服务,适用于云原生时代的容器化部署,可通过HPA弹性伸缩容来应用不同场景下的测试覆盖率上报。
七、报告
对于覆盖率报告展示,我们沿用了istanbul-report的界面风格,但是由于istanbul-report只提供了静态html文件的生成,不适合现代化前端水合数据生成html的模式,为此我们参考了它的源码,使用了monaco-editor标记源代码覆盖率。
const decorations = useMemo(() => {
if (data) {
const annotateFunctionsList = annotateFunctions(data.coverage, data.sourcecode);
const annotateStatementsList = annotateStatements(data.coverage);
return [...annotateStatementsList, ...annotateFunctionsList].map((i) => {
return {
inlineClassName: 'content-class-found',
startLine: i.startLine,
startCol: i.startCol,
endLine: i.endLine,
endCol: i.endCol,
};
});
} else {
return [];
}
}, [data]);
经过着色后的效果:
八、变更代码覆盖率
对于变更代码覆盖率,我们统计的公式是覆盖到的新增代码行/所有新增代码行。
通过配置compareTarget来指定对比目标,再联合gitlab的git diff接口获取变更代码行结合覆盖率数据计算。
/**
* returns computed line coverage from statement coverage.
* This is a map of hits keyed by line number in the source.
*/
function getLineCoverage(statementMap:{ [key: string]: Range },s:{ [key: string]: number }) {
const statements = s;
const lineMap = Object.create(null);
Object.entries(statements).forEach(([st, count]) => {
if (!statementMap[st]) {
return;
}
const { line } = statementMap[st].start;
const prevVal = lineMap[line];
if (prevVal === undefined || prevVal < count) {
lineMap[line] = count;
}
});
return lineMap;
}
九、react native 覆盖率收集方案
携程的移动端技术栈主要是react native,好消息是对于我们的插桩方案一样适用,因为都是基于babel编译。并且得力于得力于公司内部的react native项目结构统一,我们将编译时插桩做到了流水线中,在流水线中分别打包“正常包”和”插桩包“,这样搭配UI自动化可以形成一套完整的录制回放覆盖率指标收集的测试体系。
利用websocket暴露模拟器内覆盖率数据:
// 创建WebSocket连接
const socket = new WebSocket('ws://localhost:8080');
// 当WebSocket连接打开时触发
socket.onopen = () => {
console.log('Connected to coverage WebSocket server');
};
// 当收到WebSocket消息时触发
socket.onmessage = event => {
try {
if (JSON.parse(event.data).type === 'getcoverage') {
// 发送覆盖率数据
socket.send(JSON.stringify(payload));
}
} catch (e) {
console.log(e);
}
};
// 当WebSocket连接关闭时触发
socket.onclose = () => {
console.log('Disconnected from coverage WebSocket server');
};
目前携程机票部门的APP模块均已接入Canyon,经过实践istanbuljs可以很好的对其进行插桩及覆盖率数据收集,测试团队在每次生产发布前会以Canyon的覆盖率数据指标来衡量此次发布的质量情况。
十、覆盖率提升优先级列表
在用户最初接入Canyon系统时,会面临一个挑战:如果没有大量的UI自动化测试用例,大型应用的代码覆盖率会显得尤为低下。一开始,仅仅提供一个Istanbul代码覆盖率报告,并不能有效指导团队如何提高覆盖率,这让大家感到困惑和无所适从。
为了解决这个问题,我们进行了深入的调研,并发现公司已经有了一个成熟的生产环境代码覆盖率收集系统。基于这一发现,我们决定将这个系统的数据与我们自己的覆盖率数据相结合,创建了一个“覆盖率提升优先级列表”。这个列表的目的是为开发团队提供明确的指引,帮助他们了解在哪些方面可以优先提升代码覆盖率。
为了使这个指引更加科学和实用,我们制定了一个覆盖率权重公式:
生产环境覆盖率×100×0.3 + (1 - 测试覆盖率)×100×0.3 + 函数数量×0.2
通过这个公式,我们能够优先识别出那些生产环境使用率高、行数多,测试覆盖率低的代码文件,从而为开发团队提供针对性的提升建议。这样的方法不仅提高了代码质量,也增强了我们对整体覆盖率的掌控。
十一、社区推广
从这篇文章发表时起,我们将正式开源Canyon。JavaScript是时下最流行的编程语言,但是端到端测试覆盖率收集领域一直空白,我们的代码开发基于了istanbuljs,monaco editor等优秀开源项目,我们有信心推出Canyon开源可以赢得社区的反响,并且可以有大量JavaScript开发者参与进来。
Canyon在未来还有很大发展空间,例如生产环境插桩收集还未有待验证尝试,与playwright、puppeteer、cypress等自动化测试的工具还没有深度链接,这些都已经规划到了未来的开发计划中。希望在未来Canyon可以在携程及社区里有更多人参与建设。