代码覆盖(Code Coverage)是软件测试中的一种度量,描述程序中源代码被测试的比例和程度,所得比例称为代码覆盖率(摘自维基百科)。
在软件开发的质量把控中,代码覆盖率有着举足轻重的地位。与静态代码扫描不同,它专注于动态执行过程的统计,堪称测试完成度的绝佳衡量标准。就拿转转来说,当自测需求数占比高达 80% 时,代码覆盖率的保障作用愈发凸显。
转转内部已经成功的实现了Java、fe项目的增量代码覆盖率,其中fe项目的代码覆盖率于23年底上线,但是上线后存在着一些问题,导致覆盖率并不能很好的服务于质量建设。本文主要针对这些问题做了二期优化方案。
指程序中至少执行了一次的代码行占总代码行数的比例。
指程序中至少执行了一次的函数占总函数的比例。
指程序中条件判断语句的每个分支是否都执行过。
指程序中所有可能的执行路径是否都被覆盖到。
“代码插桩”就是在被测程序中插入一些探针(其实就是监测代码,本质上就是进行信息采集的代码段,可以是赋值语句或采集覆盖信息的函数调用)
编译时:在代码编译构建过程中对代码进行插桩,这种方式适用于浏览器和小程序;
运行时:在代码运行过程中进行插桩操作,这种方式适用于nodejs;
插桩前代码
function istanbul_test() {
console.log('istanbul_test');
}
function istanbul_test2() {
console.log('istanbul_test2');
}
var test = false
if (test) {
istanbul_test();
} else {
istanbul_test2();
}
插桩后代码
var __cov_GpzNBZZeWVNtWXQEPAI_7w = (Function('return this'))();
if (!__cov_GpzNBZZeWVNtWXQEPAI_7w.__coverage__) {
__cov_GpzNBZZeWVNtWXQEPAI_7w.__coverage__ = {};
}
__cov_GpzNBZZeWVNtWXQEPAI_7w = __cov_GpzNBZZeWVNtWXQEPAI_7w.__coverage__;
if (!(__cov_GpzNBZZeWVNtWXQEPAI_7w['/istanbul/index.js'])) {
__cov_GpzNBZZeWVNtWXQEPAI_7w['/istanbul/index.js'] = {
"path": "/istanbul/index.js",
"s": {
"1": 1,
"2": 0,
"3": 1,
"4": 0,
"5": 0,
"6": 0,
"7": 0,
"8": 0
},
"b": {
"1": [0, 0]
},
"f": {
"1": 0,
"2": 0
},
"fnMap": {
"1": {
"name": "istanbul_test",
"line": 1,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 25
}
}
},
"2": {
"name": "istanbul_test2",
"line": 5,
"loc": {
"start": {
"line": 5,
"column": 0
},
"end": {
"line": 5,
"column": 26
}
}
}
},
"statementMap": {
"1": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 3,
"column": 1
}
},
"2": {
"start": {
"line": 2,
"column": 4
},
"end": {
"line": 2,
"column": 33
}
},
"3": {
"start": {
"line": 5,
"column": 0
},
"end": {
"line": 7,
"column": 1
}
},
"4": {
"start": {
"line": 6,
"column": 4
},
"end": {
"line": 6,
"column": 34
}
},
"5": {
"start": {
"line": 9,
"column": 0
},
"end": {
"line": 9,
"column": 16
}
},
"6": {
"start": {
"line": 11,
"column": 0
},
"end": {
"line": 15,
"column": 1
}
},
"7": {
"start": {
"line": 12,
"column": 4
},
"end": {
"line": 12,
"column": 20
}
},
"8": {
"start": {
"line": 14,
"column": 4
},
"end": {
"line": 14,
"column": 21
}
}
},
"branchMap": {
"1": {
"line": 11,
"type": "if",
"locations": [{
"start": {
"line": 11,
"column": 0
},
"end": {
"line": 11,
"column": 0
}
}, {
"start": {
"line": 11,
"column": 0
},
"end": {
"line": 11,
"column": 0
}
}]
}
}
};
}
__cov_GpzNBZZeWVNtWXQEPAI_7w = __cov_GpzNBZZeWVNtWXQEPAI_7w['/istanbul/index.js'];
function istanbul_test() {
__cov_GpzNBZZeWVNtWXQEPAI_7w.f['1']++;
__cov_GpzNBZZeWVNtWXQEPAI_7w.s['2']++;
console.log('istanbul_test');
}
function istanbul_test2() {
__cov_GpzNBZZeWVNtWXQEPAI_7w.f['2']++;
__cov_GpzNBZZeWVNtWXQEPAI_7w.s['4']++;
console.log('istanbul_test2');
}
__cov_GpzNBZZeWVNtWXQEPAI_7w.s['5']++;
var test = false;
__cov_GpzNBZZeWVNtWXQEPAI_7w.s['6']++;
if (test) {
__cov_GpzNBZZeWVNtWXQEPAI_7w.b['1'][0]++;
__cov_GpzNBZZeWVNtWXQEPAI_7w.s['7']++;
istanbul_test();
} else {
__cov_GpzNBZZeWVNtWXQEPAI_7w.b['1'][1]++;
__cov_GpzNBZZeWVNtWXQEPAI_7w.s['8']++;
istanbul_test2();
}
插桩后的代码其实就是将源代码经过AST解析后增加了一些代码计数器的逻辑,statementMap指的是语句,fnMap指的是函数,branchMap指的是分支(代码执行分支if else),然后s,f,b分别对应这几种类型语句的执行次数。
__cov_GpzNBZZeWVNtWXQEPAI_7w.s['8']++;像这种代码就是当代码被执行时,代码计数器+1。
前面介绍了相关的前置知识,接下来我们介绍一下最初转转侧前端代码增量覆盖率方案(转转侧只关注语句覆盖率)。
这套方案中,前端使用了istanbul工具来获取覆盖率数据,进而统计行覆盖率。基于istanbul的覆盖率检测统计大致分为三个步骤:插桩、覆盖率数据收集、生成报告。
插桩:在beetle(转转自研代码构建发布平台)编译阶段,我们使用自定义的webpack插件来注入babel-plugin-istanbul,来对发生变更的代码文件完成插桩操作。在编译阶段,需要编译测试包和线上包,串行编译会导致编译时长的增加。我们通过并行编译,减少了整个编译过程的时间。这样一来,即使使用了Istanbul工具进行代码覆盖率的检测和分析,也能够保持较高的开发效率。
覆盖率数据收集和预处理:自定义的webpack插件在代码编译结束后,会在index.html文件中注入一段script脚本,根据生成的覆盖率原始数据,计算插桩的行和覆盖行,定时上报给覆盖率服务。
覆盖率报告生成:beetle平台获取到上报的覆盖率数据后,根据增量代码和上报的信息,自动更新行覆盖情况并计算得到增量代码覆盖率信息。
该方案上线后,我们发现在某些情况下,覆盖率数据存在着偏差,覆盖率报告中显示的源码行数错误(测试同学已经在测试环境进行了整个流程的回归测试,但是覆盖率数据却显示部分增量代码没有覆盖,或者覆盖行号对应错误的情况),于是我们进行了bad case的收集。
由于转转侧移动端项目选型为vue,而且正处于vue2向vue3的过渡阶段(期间还有过一段js向ts的过渡),因此vue项目中存在着多种多样写法,包括optionsAPI和compositionAPI hack的setup语法,还有vue2.7中的setup写法等等。而通过我们对收集的bad case进行分析,发现在以上三种情况中,覆盖率报告中存在着:计算插桩行信息和覆盖行信息有误的问题。
在我司的前方案中,主要是通过收集babel-plugin-istanbul产生的覆盖率数据,然后再对其进行行号解析从而得到的覆盖率报告。那么源码行数对应错误的情况要么是报告解析错误,要么是插件插桩错误。针对第一种情况,我做了相关的测试,发现报告的解析是没有问题的。那么就只能是插件插桩的问题了么?
istanbul工具是一种成熟的前端代码覆盖率技术方案,并且很多前端测试框架(例如jest、karma、cypress等)都内置了它来进行代码覆盖率的统计。理论上这么成熟的技术不不应该会出现插桩错误的情况。于是我进行了各种资料的搜集,发现github上还真有好几个issue描述的和我们的情况一致。
❝
https://github.com/iFaxity/vite-plugin-istanbul/issues/14 https://github.com/istanbuljs/babel-plugin-istanbul/issues/284 https://github.com/istanbuljs/babel-plugin-istanbul/issues/280 https://github.com/istanbuljs/babel-plugin-istanbul/issues/276 https://github.com/istanbuljs/babel-plugin-istanbul/issues/261 https://github.com/istanbuljs/istanbuljs/issues/735
难道真的是插桩的问题,只能给istanbul提pr才能解决这个问题?或者只能换一种工具了吗?于是我又抱着试一试的心态在各种技术论坛,博客冲浪,最后发现别人的覆盖率报告都是结合相应的工具生成的,并没有直接解析覆盖率数据中的line行号来生成覆盖率报告的例子。于是我锁定了nyc工具,输入覆盖率数据,使用它来生成覆盖率报告。
由于我司只在测试环境进行覆盖率报告的收集,但是测试环境的编译时间较长,并且编译脚本统一,存在着无法灵活修改编译脚本,影响开发效率的情况。所以我总结了一套“覆盖率本地调试方案”。
"istanbulDev": "cross-env ISTANBUL=1 npm run dev",
"reportCoverage": "nyc report --reporter=html"
"nyc": {
"include": [
"src/**/*.{js,ts,vue}"
],
"excludeAfterRemap": false,
"exclude": [
"node_modules",
"tests"
],
"extension": [
".js",
".vue",
".ts"
],
"report-dir": "./coverage",
"temp-directory": "./.nyc_output"
}
"src/views/basic/home/components/king-kong-comp.vue","src/views/basic/home/components/Header.vue"
pnpm install nyc -D
通过对nyc生成的覆盖率报告进行分析后发现,无论是.vue文件还是.ts,.js文件,覆盖率报告都是准确的。至于我们的源码行数对应错误的问题,是我们对覆盖率数据的理解有误,导致了我们自己通过window. __coverage__获取数据后进行解析的过程出错,然后返回了错误的结果,生成了错误的覆盖率报告。
更详细一点来说:我们主要依赖window.__coverage__中的s和statementmap来定位源代码行数,但是我们错误的理解了这个源代码行数表示的意义,例如:
"start": {
"line": 30,
"column": 11
},
"end": {
"line": 30,
"column": 33
}
我们理解就是源文件中的30行到30行,但其实istanbul只对js文件进行插桩,而.vue文件包括了template,js或者ts,scss或者less这三部分,而且在我们认为的编辑器中的一行,其实对于istanbul插件来说可能不是一行,因为插件是从AST的角度对源码进行解析,而不是从编辑器的角度看源码行数。所以导致源码行数不准确。
例如上图所示:我们理解taskList变量的定义就是源代码中的63-71行,但是对于istanbul插件来说,因为它是以AST的角度解析的,所以它认为其实是一行。
我们在本地研究覆盖率的过程中,使用涵盖了文章开头提到过的.vue文件不同写法的线上服务进行验证,利用官方命令行工具nyc生成了可视化报告,发现覆盖率结果均符合预期,解决了前期方案中计算插桩行信息和覆盖行信息有误的问题。新方案的具体流程如下:
在服务部署完成后,为了提高计算覆盖率接口的性能,会自动触发下载项目源码和计算代码变更的任务,将源码数据下载到服务器,并将代码变更数据存储到数据库,为后续使用nyc工具计算增量覆盖率提供基础数据。
在用户测试过程中,转转自定义插件会向服务端上报Istanbul生成的全量覆盖率原始数据,考虑到覆盖率获取的实时性,转转的上报时间间隔定义为10秒,但是频繁的上报会导致覆盖率收集服务接收的数据量较大,进而影响实时获取覆盖率的接口性能。为了提高查询覆盖率接口的响应速度,覆盖率服务对收集到的原始数据进行定期异步合并。具体的合并策略如下:
在处理针对同一个commit数据之前,需要再次了解下Istanbul生成的原始覆盖率数据的两个重要概念:statementMap 和 s(前文已提到过),它们是与代码覆盖率数据收集和报告生成相关的两个重要概念。
❝statementMap 是一个映射表(map),它将代码中的语句编号映射到实际的源代码行号和列号。通过statementMap,Istanbul 可以准确地知道每个语句在源代码中的位置,从而在生成覆盖率报告时提供详细的行级覆盖信息。
❝s 是一个数组,用于记录每个语句是否被执行过。数组的索引对应 statementMap 中的语句编号,值表示该语句被执行的次数。通过 s 数组,Istanbul 可以统计每个语句的执行次数,从而计算出代码覆盖率。
在了解完Istanbul基础数据格式后发现,对于多次上报的数据,直接合并s中语句索引对应的执行次数即可,生成的原始覆盖率数据示例如下。
// statementMap:“0”表示语句索引,下面这段对象表示第0条语句在源码17行到25行之间;
"0": {
"start": {
"line": 17,
"column": 0
},
"end": {
"line": 25,
"column": 16
}
}
// s:表示第0条语句执行了1次;
"0": 1,
覆盖率统计服务会在每次重新部署最新代码后,自动触发一次行覆盖率的统计任务,计算上次增量代码最新的行覆盖率数据。而后获取最新代码变更记录,根据每个类中代码行内容,自动继承上一次任务的覆盖情况,从而完成不同commit的覆盖率合并。
第一期方案中覆盖率上报为周期上报,如果没有变更也会上报,会给覆盖率系统的接收和计算覆盖率带来很多重复的数据。本次我们对上报逻辑进行优化,对于重复数据不再上报,可以从根源上解决此问题,大大降低计算的复杂度,提高覆盖率查询接口的响应速度。
每次查询覆盖率都会自动触发增量代码行覆盖统计流程,将合并后的浏览器上报的覆盖率数据复制到项目源码目录的 .nyc_output 文件夹下,并使用nyc工具自动生成行覆盖率报告存储在当前目录的coverage文件夹下。为了减少对项目的入侵,并确保配置的灵活性,所有参数均通过 nyc 命令直接指定。具体的命令如下图所示。
只统计js,ts,vue 三种类型文件的覆盖率,同时依赖和测试代码,为了方便解析,采用生成clover格式。原始数据放在.nyc_output,结果数据放在coverage目录
npx nyc --include "src/**/*.{js,ts,vue}" --exclude-after-remap false --exclude "node_modules" --exclude "tests" --extension .js --extension .vue --extension .ts --report-dir ./coverage --temp-directory ./.nyc_output report --reporter=clover
❝NYC(Istanbul 的 CLI 工具)支持多种不同格式的代码覆盖率报告,以满足不同的需求。以下是常见的几种报告格式:
1.HTML:生成交互式的 HTML 报告,便于浏览和查看详细的覆盖率信息。
2.LCov:用于与 LCOV 兼容的工具,如 SonarQube。
3.JSON:生成 JSON 格式的覆盖率数据,便于程序处理。
4.Text:生成纯文本格式的报告,适合命令行输出。
5.Clover:生成 Clover XML 格式的报告。
经过测试实践,clover格式的数据是程序解析行覆盖最适合的格式。简单示例如下:
<?xml version="1.0" encoding="UTF-8"?>
<coverage generated="1739000133371" clover="3.2.0">
<project timestamp="1739000133371" name="All files">
<metrics statements="25" coveredstatements="24" conditionals="5" coveredconditionals="5" methods="8" coveredmethods="8" elements="38" coveredelements="37" complexity="0" loc="25" ncloc="25" packages="1" files="1" classes="1"/>
<file name="main.ts" path="src/main.ts">
<metrics statements="25" coveredstatements="24" conditionals="5" coveredconditionals="5" methods="8" coveredmethods="8"/>
<line num="16" count="1" type="stmt"/>
<line num="20" count="1" type="stmt"/>
<line num="22" count="1" type="stmt"/>
<line num="74" count="0" type="stmt"/>
</file>
</project>
</coverage>
通过解析clover格式结果文件,可以获取文件的路径、文件名、行号和该行执行的次数。比如上图中的示例,可以解析出src/main.ts中第16、20、22行被执行了1次,74行没有被执行过。再结合代码数据预处理阶段获取的增量代码变更记录,即可完成增量代码行覆盖率的计算。
在我们测试过程中,发现有两类情况需要特殊处理:
一类是页面渲染相关代码行不会出现在clover格式的结果文件中,但是页面渲染会自动执行这部分代码。因此在生成覆盖率报告时,覆盖率服务会将这部分代码自动设置成已覆盖。
另外一类,因为Istanbul开源工具不会给模板和CSS插桩,如果一个文件中只包含不可以插桩的代码,生成的clover格式的结果文件不会生成该文件的信息,因此需要在生成覆盖率报告时,覆盖率服务对这类文件设置成已覆盖。
本文详细介绍了转转近期通过使用 NYC(Istanbul 的 CLI 工具)生成代码覆盖率报告,成功解决了前期自定义插件在解析 Istanbul 原始数据时遇到的问题。此前,由于插桩行和覆盖行的解析不准确,导致覆盖率数据存在偏差,影响了代码质量评估的准确性。 通过引入 NYC 工具,转转团队不仅修复了这些解析错误,还显著提升了覆盖率数据的可靠性和准确性。目前,该功能已经在转转内部得到了广泛应用,取得了显著的效果。具体表现在以下几个方面:
提高代码质量:准确的覆盖率数据帮助开发人员更好地识别未覆盖的代码路径,从而优化测试用例,确保每个功能模块都经过充分验证。
保障线上稳定性:覆盖率报告为持续集成和部署提供了有力支持,确保每次发布都能达到预期的质量标准,降低了线上故障的风险。
综上所述,NYC 工具的成功应用不仅解决了之前的技术难题,还为转转的代码质量和线上稳定性提供了坚实的保障。未来,转转将继续探索更多提升代码质量的方法和技术,欢迎各位同仁一起沟通交流。