基于 istanbul 的前端代码覆盖率统计实战
引言代码覆盖率对于项目的质量和可靠性至关重要。它是衡量测试覆盖程度的关键指标,可以帮助开发团队评估他们的测试范围和代码质量。发现潜在的代码错误:确保在各种场景下都对代码进行了适当的测试,从而减少潜在的错误和缺陷。降低维护成本:通过代码覆盖率测试,可以更早地发现和修复潜在的问题。这有助于减少在生产环境中出现的 bug 数量,从而降低维护成本。提高代码质量:促使开发团队编写更健壮、可靠的代码。增加团队
引言
代码覆盖率对于项目的质量和可靠性至关重要。它是衡量测试覆盖程度的关键指标,可以帮助开发团队评估他们的测试范围和代码质量。
发现潜在的代码错误:确保在各种场景下都对代码进行了适当的测试,从而减少潜在的错误和缺陷。
降低维护成本:通过代码覆盖率测试,可以更早地发现和修复潜在的问题。这有助于减少在生产环境中出现的 bug 数量,从而降低维护成本。
提高代码质量:促使开发团队编写更健壮、可靠的代码。
增加团队信心:高代码覆盖率可以给开发团队带来信心,因为他们知道代码已经经过了全面的测试。
尽管代码覆盖率的统计对研发和测试人员都有着非常重要的意义,但社区提供的前端代码覆盖率统计方案却非常有限。这无疑是由于统计前端代码覆盖率有一定难度。
今年 Q3 我们开始基于 istanbul 建设前端代码覆盖率的统计流程,并在公司内各团队进行推广。istanbul 是一个 JavaScript 代码覆盖率工具,用于帮助研发和测试人员评估他们的代码测试覆盖率。它通过分析 js 代码的运行情况,生成报告以显示哪些部分被测试套件覆盖,哪些未被覆盖。这有助于指导改进测试策略和确保代码质量。istanbul 支持多种报告格式,使我们能够定制和集成测试覆盖率数据到开发流程中。
下文将会详细介绍我们从技术选型、统计流程设计,到实际落地的全链路流程。
注意:后文中我们提到的“代码覆盖率”都仅指“前端 client 端代码覆盖率”,不包含 nodejs 作为后端服务的代码覆盖率。
1理解代码覆盖率
1.1 什么是代码覆盖率
代码覆盖率计算是衡量测试覆盖程度的一种方法,它可以评估代码中被测试覆盖的部分与总代码量(或本次迭代的增量代码)的比例。具体来说,又可以分为“语句覆盖”“分支覆盖”“函数覆盖”。一般来说,对于我们最终得到的那个覆盖率百分比,即整个程序在测试过程中被覆盖的百分比,使用的是“语句覆盖”的值,因为语句覆盖一定程度上也包含了分支和函数覆盖。
下图来自 cypress(一个受欢迎的 E2E 前端测试框架) 官网,它是一份前端代码覆盖率报告。
点击某个具体文件,还可以查看文件中某个函数或语句的具体执行情况。红色和黄色为未覆盖部分,是最值得关注的。
1.2 为什么要建设代码覆盖率
当我们得到如上图所示的覆盖率报告,就能量化当前程序代码被覆盖的比例,从而制定一些测试和上线规范。比如规定前端项目的覆盖率数据必须超过某个特定数值才被认为是被充分测试的,否则不允许项目上线。
研发和测试人员也可以通过查看某个具体文件的覆盖情况,针对性进行测试。如果他们发现某段代码一直不能被覆盖,说明这段代码有可能存在问题。
我们假设研发在开发某个迭代时,出于某些未知原因,修改了一些与本次迭代功能毫不相关的代码。如果研发不告诉测试人员,测试人员也没有充分 review 过研发的代码,测试人员就不会知道这部分有改动,很可能疏忽了对这部分代码的测试。当我们有了代码覆盖率报告,就可以在一定程度上防止这样的情况出现。因为研发对代码的任何改动都会反映到覆盖率报告上。
1.3 统计覆盖率的基本步骤
与其他语言的覆盖率统计步骤一致,前端代码覆盖率统计大致可以分为三个步骤:插桩--覆盖率数据收集--生成报告。
1.3.1 插桩(instrument)
插桩是通过在源代码中插入特定的监测代码,来跟踪程序运行过程中的路径和条件分支的执行情况。istanbul 只会对 js 代码插桩,也就是仅跟踪我们程序的逻辑执行。
这里有一个示例来说明什么是插桩:
插桩后,上面的代码看起来像是这样:
可以看到我们用 c 收集了 add.js 文件中函数和语句的执行次数,由此就能知道代码有没有被覆盖(是否执行过)。
这里的 window.__coverage__ 最终收集到的数据就是一个简化的“覆盖率对象”。实际上,window.__coverage__ 中记录的数据会比上述示例要更复杂,这里我们暂不探讨。
可以看到,插桩会侵入源码,istanbul 统计前端代码覆盖率的这套方案从设计上即是如此。但可以肯定的是,插桩并不会对我们的代码逻辑有影响,只会在代码中插入一些计数器,因此会增加我们项目打包后的大小 5%~20%。
一些接入方担心这种插桩导致的代码量增多,会对他们的测试环境性能有所影响。一般情况下,这种性能影响非常小。但在某些情况下,特别是对于性能要求极高的代码段或对于资源受限的环境(比如移动设备),插桩可能会引起一定程度的性能下降(这种情况建议单独在不插桩的情况下进行性能测试)。我们要求只在测试环境 build 代码时进行插桩和覆盖率统计,因此不会影响生产环境的代码。
后文还将继续说明为什么牺牲测试环境的一点性能以接入前端代码覆盖率是能够被接受的。
1.3.2 覆盖率数据收集
从我们在浏览器中开始进行功能测试,到我们关闭浏览器,这个过程中产生的覆盖率数据是累计的。如果我们能够获取到本次测试的“覆盖率对象”并将其存储起来,就能用来生成本次访问的覆盖率报告。
你可能会注意到上面我说的都是“本次”,是因为下一次打开浏览器测试的覆盖率数据会重新生成。这里我们还是使用简化的“覆盖率对象”来举例。
第一次我们在浏览器中测试,产生的覆盖率数据如下:
但是当我们再次开启一个新的浏览器页签执行测试时,这时候又会针对本次测试从0开始计算覆盖率数据。我们又得到了一个新的“覆盖率对象”:
对于我们的测试人员来说,多次测试和重复测试是非常普通的操作。这期间产生的任何覆盖率数据都应该成为我们覆盖率报告的一部分。因此我们还需要有一个服务,将多次测试产生的“覆盖率对象”合并在一起。
1.3.3 生成覆盖率报告
生成覆盖率报告是将我们的覆盖率数据可视化,以方便研发和测试人员查看代码的执行和覆盖情况。也就是生成 1.1 中示例的报告那样。
你可能已经想到了一些大致需要干的事情:我们需要拉取 git 仓库源码,对比本次迭代产生的 diff 代码。以及如上文所说,将多次测试产生的覆盖率数据合并在一起,最后再生成此次迭代增量代码的覆盖率报告。
但这里还涉及到很多细节:如何拿到当前部署代码的分支?测试过程中更改代码后重新部署应用,新产生覆盖率数据能否直接合并到之前累计的覆盖率数据中?
2 代码覆盖率统计
2.1 工具选择
这里列举了一些统计前端代码覆盖率的工具:
对于 jest、karma、cypress 这样的前端测试框架,一般都内置了代码覆盖率统计的内容。如果你查阅他们的文档,可以发现他们都在用一个叫 istanbul 的工具。或者你直接检索“前端代码覆盖率”相关的内容,大家也都在提 istanbul 或 nyc(nyc 是 istanbul 的命令行工具)。
同 java 覆盖率工具 jacoco 一样,Istanbul 可以算得上是多年来覆盖率领域内较为成熟的技术方案。尽管 istanbul 的维护已经不活跃了,但它几乎是我们能够选择的唯一方案。诸多前端测试框架的覆盖率统计都使用了它,在一定程度上也能说明 istanbul 是可行的。
istanbul 向我们提供了插桩、合并和生成覆盖率报告的工具,但并没有一套完整的解决方案。我们基于 istanbul 设计了覆盖率统计流程,并结合公司内部已有的后端代码覆盖率统计平台,最终完成了前端代码覆盖率的统计和可视化工作。
2.2 统计流程设计
具体来说,在我们的覆盖率统计流程中,从前端产生覆盖率数据到后端生成可视化报告,需要经过四个主要步骤:代码插桩、前端上报覆盖率数据、后端收集数据和报告生成。
2.2.1 代码插桩
插桩是 istanbul 为我们提供的功能,对源码具有侵入性,会增大代码打包后的体积。但我们对于覆盖率的统计只在程序测试阶段,插桩代码并不会被带到生产环境上去。
istanbul 官方推荐在前端项目 build 时对代码进行插桩,即使用 babel 或 vite 等工具插件。这也就意味着,不使用 babel 或 vite 的项目无法插桩,更无法做覆盖率统计。如我们有一小部分 swc 打包的 nextjs 项目就直接被卡在了这一步。
前端项目从打包工具、各种框架上来说,都有着诸多选择。这里我们预先对常见的多种项目类型都进行了充分的插桩调研:
注意:这里无需区分 CSR 或 SSR ,比如 nextjs + babel 项目其实也是 react + babel,是能够正常插桩的。
项目插桩以后,如果可以在浏览器的控制台打印出如下的覆盖率对象,那么恭喜你,已经完成了我们覆盖率统计的第一步。
2.2.2 前端上报覆盖率数据
我们为上报覆盖率数据开发了自己的 sdk。可能听起来上报覆盖率数据是一件很容易的事情,我们只需要把上一步插桩后的覆盖率对象通过 http 请求发送给后端即可。但我们在这个上报工具中还处理了一些跟后续数据收集和报告生成有关的细节。
这里需要说明的是,目前我们上报数据的 sdk 是为浏览器中 js 代码的执行提供的,不能用于 nodejs 服务端 js 代码的执行上报。
我们在 1.3.3 的末尾提到一个问题:如何拿到当前部署代码的分支?目前为止,即使我们将覆盖率对象成功发送给后端,后端也不知道这个覆盖率数据来自哪个应用的哪个代码分支,那就无法得知该如何进行代码 diff。
得益于我们的基础技术团队,在此之前就做了一些工作,会将代码分支和 commitId 信息以 cookie 的形式返回给前端。于是在前端上报覆盖率数据时,将分支信息一同带给后端。
另一个关键的问题是,我们应该在什么时机去上报覆盖率数据?
前面已经提到,在一次页面访问中,直到关闭页面前,覆盖率数据是累计的。因此,理想的情况是,我们在页面关闭之前,上报一次覆盖率数据即可。但实际情况是,我们无法保证在页面销毁前进行上报。上报覆盖率数据的操作是异步的,即使是在一些页面销毁前的钩子如 beforeunload 中也无法做到,更别说移动端很多 webview 还不支持这些页面销毁前钩子了。
为了尽量减少测试过程中覆盖率数据的丢失,我们采用了定时上报为主,页面其他事件为辅上报的方式。同时防止测试页面长期挂在后台,监听页面的可见性 API visibilitychange 来停止或开启上报任务。
到这里你可能会注意到一个问题,以上的自动上报方案会导致对于同一个覆盖率数据重复上报。而我们的覆盖率数据又是累计和合并的,因此在覆盖率报告上代码执行的次数显示将会远大于实际执行。如下面的报告中显示 149 行和 151 行代码执行了 22 次,但实际上可能远小于 22 次。之所以我们不认为这是一个问题,是由于我们关心的其实是报告上没有被覆盖的部分,被覆盖部分的执行次数并不是当前重点。
目前我们使用 POST 请求的 body 去上报覆盖率对象。但对于体量越大的前端项目,覆盖率对象就会越大,POST 请求的速度也会越慢。由于覆盖率对象是一个 json,对 json 进行一定的分割上报也是我们后续优化的一个方向。
2.2.3 后端收集数据&报告生成
后端服务收到前端上报数据后的步骤如下图:
后端拿到覆盖率数据后,先校验数据的完整性。随后根据应用标识(AppNid)和代码分支进行数据隔离,防止不同服务、同服务不同分支的覆盖率数据互相影响。覆盖率数据的存储和处理流程涉及一系列的文件操作,通过数据处理流程分布式锁规避多终端上报的文件占用及写入异常问题。之后对数据进行存量合并和冲突处理,最后将处理完成的覆盖率 json 数据按照隔离规则写入对应路径,用于后续增量或全量报告的生成。
这里我们将着重分享覆盖率对象合并和冲突处理的过程。
首先我们来聊一下覆盖率对象合并的操作。在 1.3.3 中我们提到,测试过程中更改代码后重新部署应用,新产生覆盖率数据能否直接合并到之前累计的覆盖率数据中。换句话来说,同分支不同 commitId 的覆盖率数据能否直接合并?答案是否定的。
试想我们的开发分支上现在有两个 commit,commit 1 和 commit 2。在 commit2 上我们仅仅给 app.tsx 文件的第 15 行之前增加了一行空行,那么 15 行之后所有的代码行数都会发生改变。如果你展开覆盖率对象可以发现,json 数据有严格的行号对应,比如一个函数从哪一行开始,到哪一行结束。
对于同一个函数,nyc 规定了当两个覆盖率对象中函数 line column 完全一致才会正常合并,否则会保留二者。所以对于有修改的文件,他在修改前和修改后的覆盖率数据不能正常合并。
但是仔细回想,实际上我们真正想要测试的是最新的 commit 2 上的代码版本。于是对于有修改的 app.tsx 文件,我们直接舍去它之前的覆盖率数据,重新测试即可。对于没有修改的其他文件,我们依然可以正常与之前的覆盖率数据合并。
于是,我们的后端服务在执行 nyc merge 合并两个覆盖率对象的过程中,会对比覆盖率对象中相应路径的唯一 contentHash 值,如果两个对象的 hash 值相同,则直接合并。hash 值不相同,说明文件有改动,前后覆盖率对象就会产生冲突,仅有新的 hash 值代表的覆盖率数据会被保留下来。从而规避了因代码改动导致插桩点位不匹配,而强行合并造成的数据错乱甚至丢失的情况。
报告生成时还有一个重要操作就是进行 diff,我们需要生成本次迭代增量的覆盖率报告。
前端会对代码进行全量插桩,上报过来的覆盖率对象也是“全量”的。你可能会注意到,后一个“全量”我使用了引号,我很快就会解释这一点。
如果想要得到 diff 后的覆盖率报告,需要我们裁剪一下覆盖率 json。做法也很简单,因为覆盖率对象是以源文件 path 作为 key 的,我们将本次迭代没有任何修改的文件数据直接舍去即可。以下图为例,如果某次迭代只对 App.js 和 Header.js 进行了代码修改,就仅留下这两个 path 对应的数据,这样生成的覆盖率报告上也只会有这两个 path 的数据。
现在来讲讲上报过来的覆盖率对象是“全量”的这件事。前端工程师常常会使用叫做“异步加载”的页面优化方法,具体来说就是即将用到某一段代码,才会加载进来。换言之,在没有运行到特定的逻辑之前,我们拿不到这段异步加载代码的覆盖率数据。
再举个具体的例子:我们的前端工程师现在有两个路由页面,当分别访问 A、B 页面的路由时,他们对应的 js 资源才会被加载进来。假设本次迭代只涉及到 A 页面的代码修改。但由于一些未知的原因,我们的研发在 B 页面也做了一些代码修改,并且没有告诉测试人员。在整个测试过程中,测试人员都在访问 A 页面,而没有访问 B 页面,因此与 B 页面相关的任何覆盖率数据都不会上报后端。即使是从覆盖率报告上,也丝毫看不出我们的研发在 B 页面有代码修改。
因此仅对覆盖率对象做裁剪其实还不够,我们还需要根据 diff 往覆盖率对象里面增添一些空数据。这些空数据表示该文件有代码修改,但却没有数据上报。这样能帮助我们的测试人员知道,研发有一些改动的文件可能完全没有被测试到。
目前为止,对于单个文件来说,覆盖率数据还是全量的,即覆盖率报告是文件级别的。这意味着:如果你在 App.js 文件中更改了一行代码,那么整个 App.js 中的 js 代码都需要重新测试。这增加了测试人员需要覆盖的代码范围,不太合理。
java 的覆盖率报告之所以能精确到函数级别,得益于能获取到每个函数的函数名。而我们的前端工程师们都喜欢大量使用匿名方法,因此不能采用像 java 一样的方式去将覆盖率报告精确到函数级别。
在覆盖率接入推广过程中,我们一直在思考怎么能将前端的覆盖率报告也精确到函数级别。幸运的是,随着对覆盖率对象的数据结构和 nyc 内部逻辑的更深入了解,我们想出了可能的解决方案。这要求我们在执行 nyc report 生成覆盖率报告时做一些额外的工作:我们拉取 git 上相应版本的代码,获取到每个 path 对应的文件中哪些 line 进行了修改,从而将覆盖率对象中每个 path 对应的数据进一步修剪,去掉那些与我们代码改动无关的函数和分支。最终我们得到了函数甚至条件分支级别的前端覆盖率报告。
在如下的代码示例中,研发只更改了 resetCheckForm 方法中的某几行代码。生成的函数级别覆盖率报告将不会包含 formSave 方法,覆盖率百分比的计算也只会涵盖 resetCheckForm 方法的相关行。于是测试人员只需要关注 resetCheckForm 方法有没有被覆盖即可。
2.2.4 覆盖率能力的输出及应用
覆盖率作为衡量测试充分度的重要指标,是质量体系中的重要一环,接入高途现有的发布编排质量门禁是常态化能力输出及价值最大化的关键步骤。我们已将覆盖率统计封装为质量能力单元,并可为各上层平台系统提供覆盖率输出能力。
覆盖率能力单元处理流程可简述为:克隆->增量分析->分支数据合并->数据剪枝->报告生成。
以发布编排引入前端覆盖率能力为例,覆盖率能力输出流程如下,可以看到接入逻辑及处理流程已经非常简化。
融入高途现有质量体系效果如下,可为准出门禁提供重要衡量数据指标。我们规定覆盖率数据达到门禁指标后才可正常发布上线。
注意:当有多个应用发布时,覆盖率数据会根据行覆盖率取均值。
2.3 实战案例
前端项目在项目提测前,研发人员可以接入插桩插件和覆盖率上报 sdk。之后研发人员就无需进行操作,等待测试人员正常测试即可。
我们将代码覆盖率统计集成在了上线流程中,研发和测试人员可以在测试过程中触发代码覆盖率统计,查看当前测试对代码的覆盖程度。某次统计生成的覆盖率报告如下:
测试人员注意到 config 和 mock/data/formList 文件夹为灰色,询问研发这两个文件的情况,得知这两个文件夹下分别为 umi 配置和 mock 文件,与业务逻辑无关。测试人员可进一步查看:src/components/NewEditForm/FieldItem 文件夹,检查是哪些逻辑没有被覆盖到,是否有关键逻辑。
到目前为止,这个项目本次迭代的覆盖率已经达到 92.02%,超过我们设置的上线卡点 70%,因此能够正常申请上线。
项目灰度时,测试人员发现产生了一个逻辑 bug,通过页面初步定位到是src/components/NewEditForm/FieldItem 中某个文件的问题。测试人员通过查看测试环境的覆盖率报告,发现研发人员对一个与本次迭代无关的函数进行了优化,并且这段代码没有被覆盖到。测试人员猜测是这段代码产生了 bug,随即在测试环境进行验证,最终定位到确实是这段代码的问题。随后研发进行修改,测试人员再次进行验证,覆盖率报告上这段代码也已经被覆盖,问题闭环。
由以上的案例看来,高代码覆盖率并不意味着代码就没有逻辑错误,更无法保证代码的质量、性能。尽管代码覆盖率是评估测试套件覆盖代码的一个有用指标,可以帮助发现未被测试覆盖的代码区域,但它并不能解决所有与代码质量和软件开发相关的问题。
另外,我们的覆盖率统计平台也支持生成全量的覆盖率报告。如果有需要查看全量的报告,也可以手动执行并查看。
3 实战总结
社区关于前端代码覆盖率的解决方案非常少,建设的成本和难度较大。这主要是由于:不同于一些后端开发流程,前端开发中技术栈和工具生态非常多样化,新的框架、工具也层出不穷。这种多样化使得提供合适的工具和方法进行代码覆盖率的评估可能会因为框架、工程、打包工具的不同而各有不同,编写通用性的指南和教程也更加困难。另外,前端开发者需要关注用户体验、性能优化等方方面面,覆盖率并不是前端开发人员优先考虑的。
使用 istanbul 和 nyc 用于插桩和生成覆盖率报告,在很大程度上攻克了我们流程中“代码插桩”和“报告生成”两大重要部分。但在开发阶段,我们还是遇到了很多困难。
Istanbul 开源版本中存在两个阻碍落地使用的问题:一是强依赖浏览器中网页状态,刷新后覆盖率丢失。二是不支持增量统计,但全量的覆盖率统计对真实的测试场景帮助不大。针对以上两个问题,为了保障前端覆盖率数据的准确性、完整性和易用性,我们对开源版本进行了二次开发及定制化处理。
无论是插桩插件,还是 nyc 命令行工具,我们都对其进行了很多 bug 修复和一些二次开发,以满足我们生成报告的需要。包括但不限于以下几点:
浏览器覆盖率数据上报策略,包括页面加载、路由变化、焦点变化及定时等上报策略,保障测试中覆盖率数据的完整性。
优化原始版本的合并策略,支持存量合并(并非单纯新的数据替换旧的数据),保障覆盖率数据的准确性。
进行代码增量及覆盖率数据的解析及剪枝,支持文件级和方法级(目前灰度测试中)增量报告的产出,保障覆盖率数据的易用性。
前端开发人员在初次接入覆盖率插件时有一定学习和阅读文档的成本,后续可以在 0.5h 内快速完成单个项目接入。对于测试人员来说,一定会增加一些测试成本。在我们对 nyc 二次开发支持了函数粒度增量覆盖率后,增加的测试成本能控制在一个较小且合理的程度。只有当研发切实对某段代码进行了修改,测试人员才需要对这段代码所在的函数或分支进行重新覆盖。
目前公司内前端项目的代码覆盖率整体接入率为 49% 。原则上,有迭代的前端项目应该应接尽接,只需要我们提供可行的插桩工具和收集覆盖率数据的方法即可。也还存在一些项目类型暂无法接入,主要是由于我们还无法提供可行的插桩工具,或者在上报过程中有卡点。
自我们开始推广前端代码覆盖率的接入工作,Q4 至今通过覆盖率拦截的有效 Bug 数为 17 例,且近两期公司内部的 bug 评选活动中有 6 例为覆盖率拦截,证明覆盖率的接入对于 Bug 的拦截及深层 Bug 的发现是有着很大价值的。
后续我们还将不断探索新的工具和方法,完善前端代码覆盖率的统计链路,支持更多项目类型的接入,帮助开发和测试人员发现和解决代码问题,努力为用户创造出更加稳定、可靠的产品体验。
附录
参考资料:
Cypress-Tooling-Code Coverage:https://docs.cypress.io/guides/tooling/code-coverage#E2E-code-coverage
istanbul.js:
https://istanbul.js.org/
Github-nyc:
https://github.com/istanbuljs/nyc
END
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)