cover_image

转转前端覆盖率优化方案

强敏/王悦 大转转FE 2025年02月10日 01:00

图片图片

背景

代码覆盖(Code Coverage)软件测试中的一种度量,描述程序源代码被测试的比例和程度,所得比例称为代码覆盖率(摘自维基百科)。

在软件开发的质量把控中,代码覆盖率有着举足轻重的地位。与静态代码扫描不同,它专注于动态执行过程的统计,堪称测试完成度的绝佳衡量标准。就拿转转来说,当自测需求数占比高达 80% 时,代码覆盖率的保障作用愈发凸显。

转转内部已经成功的实现了Javafe项目的增量代码覆盖率,其中fe项目的代码覆盖率于23年底上线,但是上线后存在着一些问题,导致覆盖率并不能很好的服务于质量建设。本文主要针对这些问题做了二期优化方案

前置知识

基本的覆盖率准则

行覆盖率(Line Coverage)

指程序中至少执行了一次的代码行占总代码行数的比例。

函数覆盖率(Function Coverage)

指程序中至少执行了一次的函数占总函数的比例。

分支覆盖率(Branch Coverage)

指程序中条件判断语句的每个分支是否都执行过。

路径覆盖率(Path Coverage)

指程序中所有可能的执行路径是否都被覆盖到。

如何进行代码插桩

什么是代码插桩

“代码插桩”就是在被测程序中插入一些探针(其实就是监测代码,本质上就是进行信息采集的代码段,可以是赋值语句或采集覆盖信息的函数调用)

什么时候进行插桩

编译时:在代码编译构建过程中对代码进行插桩,这种方式适用于浏览器和小程序;

运行时:在代码运行过程中进行插桩操作,这种方式适用于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。

前覆盖率方案

前面介绍了相关的前置知识,接下来我们介绍一下最初转转侧前端代码增量覆盖率方案(转转侧只关注语句覆盖率)。转转fe增量代码覆盖率统计流程

这套方案中,前端使用了istanbul工具来获取覆盖率数据,进而统计行覆盖率。基于istanbul的覆盖率检测统计大致分为三个步骤:插桩覆盖率数据收集生成报告

插桩:在beetle(转转自研代码构建发布平台)编译阶段,我们使用自定义的webpack插件来注入babel-plugin-istanbul,来对发生变更的代码文件完成插桩操作。在编译阶段,需要编译测试包和线上包,串行编译会导致编译时长的增加。我们通过并行编译,减少了整个编译过程的时间。这样一来,即使使用了Istanbul工具进行代码覆盖率的检测和分析,也能够保持较高的开发效率。

覆盖率数据收集和预处理:自定义的webpack插件在代码编译结束后,会在index.html文件中注入一段script脚本,根据生成的覆盖率原始数据,计算插桩的行和覆盖行,定时上报给覆盖率服务。

覆盖率报告生成:beetle平台获取到上报的覆盖率数据后,根据增量代码和上报的信息,自动更新行覆盖情况并计算得到增量代码覆盖率信息。

存在问题

该方案上线后,我们发现在某些情况下,覆盖率数据存在着偏差,覆盖率报告中显示的源码行数错误(测试同学已经在测试环境进行了整个流程的回归测试,但是覆盖率数据却显示部分增量代码没有覆盖,或者覆盖行号对应错误的情况),于是我们进行了bad case的收集。

  • vue2 + 装饰器
  • vue2.6 + compositionAPI
  • vue2.7 + setupAPI

由于转转侧移动端项目选型为vue,而且正处于vue2向vue3的过渡阶段(期间还有过一段js向ts的过渡),因此vue项目中存在着多种多样写法,包括optionsAPI和compositionAPI hack的setup语法,还有vue2.7中的setup写法等等。而通过我们对收集的bad case进行分析,发现在以上三种情况中,覆盖率报告中存在着:计算插桩行信息和覆盖行信息有误的问题。

解决思路

问题分析

在我司的前方案中,主要是通过收集babel-plugin-istanbul产生的覆盖率数据,然后再对其进行行号解析从而得到的覆盖率报告。那么源码行数对应错误的情况要么是报告解析错误,要么是插件插桩错误。针对第一种情况,我做了相关的测试,发现报告的解析是没有问题的。那么就只能是插件插桩的问题了么?

istanbul工具是一种成熟的前端代码覆盖率技术方案,并且很多前端测试框架(例如jest、karma、cypress等)都内置了它来进行代码覆盖率的统计。理论上这么成熟的技术不不应该会出现插桩错误的情况。于是我进行了各种资料的搜集,发现github上还真有好几个issue描述的和我们的情况一致。

  1. https://github.com/iFaxity/vite-plugin-istanbul/issues/14
  2. https://github.com/istanbuljs/babel-plugin-istanbul/issues/284
  3. https://github.com/istanbuljs/babel-plugin-istanbul/issues/280
  4. https://github.com/istanbuljs/babel-plugin-istanbul/issues/276
  5. https://github.com/istanbuljs/babel-plugin-istanbul/issues/261
  6. https://github.com/istanbuljs/istanbuljs/issues/735

难道真的是插桩的问题,只能给istanbul提pr才能解决这个问题?或者只能换一种工具了吗?于是我又抱着试一试的心态在各种技术论坛,博客冲浪,最后发现别人的覆盖率报告都是结合相应的工具生成的,并没有直接解析覆盖率数据中的line行号来生成覆盖率报告的例子。于是我锁定了nyc工具,输入覆盖率数据,使用它来生成覆盖率报告。

覆盖率本地调试方案

由于我司只在测试环境进行覆盖率报告的收集,但是测试环境的编译时间较长,并且编译脚本统一,存在着无法灵活修改编译脚本,影响开发效率的情况。所以我总结了一套“覆盖率本地调试方案”。

  1. 在package.json中增加以下命令
"istanbulDev""cross-env ISTANBUL=1 npm run dev",
"reportCoverage""nyc report --reporter=html"
  1. 在package.json中增加nyc配置
"nyc": {
    "include": [
      "src/**/*.{js,ts,vue}"
    ],
    "excludeAfterRemap"false,
    "exclude": [
      "node_modules",
      "tests"
    ],
    "extension": [
      ".js",
      ".vue",
      ".ts"
    ],
    "report-dir""./coverage",
    "temp-directory""./.nyc_output"
  }
  1. 项目根目录下增加diffFileList.conf(模拟beetle只收集增量覆盖率)
"src/views/basic/home/components/king-kong-comp.vue","src/views/basic/home/components/Header.vue"
  1. 本地安装nyc
pnpm install nyc -D
  1. 启动 pnpm run istanbulDev
  2. istanbulDev命令启动成功后,在浏览器运行diffFileList.conf中配置的文件涉及到的路由,在控制台打印window.__coverage__(如果控制台打印不出window.__coverage__,则需要确定一下@zz-common/zz-coverage库是不是会在开发环境下引入)
  3. 在根目录下新建.nyc_output文件夹,用当前时间戳命名新建一个json文件,然后将window.__coverage__的内容复制进去
  4. 运行pnpm run reportCoverage,即可生成覆盖率报告
图片
图片
图片

结论

通过对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生成了可视化报告,发现覆盖率结果均符合预期,解决了前期方案中计算插桩行信息和覆盖行信息有误的问题。新方案的具体流程如下:转转fe增量代码覆盖率统计流程

代码相关数据的预处理

在服务部署完成后,为了提高计算覆盖率接口的性能,会自动触发下载项目源码和计算代码变更的任务,将源码数据下载到服务器,并将代码变更数据存储到数据库,为后续使用nyc工具计算增量覆盖率提供基础数据。

覆盖率基础数据收集与合并

在用户测试过程中,转转自定义插件会向服务端上报Istanbul生成的全量覆盖率原始数据,考虑到覆盖率获取的实时性,转转的上报时间间隔定义为10秒,但是频繁的上报会导致覆盖率收集服务接收的数据量较大,进而影响实时获取覆盖率的接口性能。为了提高查询覆盖率接口的响应速度,覆盖率服务对收集到的原始数据进行定期异步合并。具体的合并策略如下:

  1. 同一个commit的数据合并

在处理针对同一个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,
  1. 修改代码重新提交导致commit不一致场景的数据合并

覆盖率统计服务会在每次重新部署最新代码后,自动触发一次行覆盖率的统计任务,计算上次增量代码最新的行覆盖率数据。而后获取最新代码变更记录,根据每个类中代码行内容,自动继承上一次任务的覆盖情况,从而完成不同commit的覆盖率合并。

  1. 数据上报的优化

第一期方案中覆盖率上报为周期上报,如果没有变更也会上报,会给覆盖率系统的接收和计算覆盖率带来很多重复的数据。本次我们对上报逻辑进行优化,对于重复数据不再上报,可以从根源上解决此问题,大大降低计算的复杂度,提高覆盖率查询接口的响应速度。

覆盖率结果的生成与增量覆盖率的计算

  1. 使用nyc工具生成行覆盖率数据

每次查询覆盖率都会自动触发增量代码行覆盖统计流程,将合并后的浏览器上报的覆盖率数据复制到项目源码目录的 .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
  1. 覆盖率结果数据格式选择与解析

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 工具的成功应用不仅解决了之前的技术难题,还为转转的代码质量和线上稳定性提供了坚实的保障。未来,转转将继续探索更多提升代码质量的方法和技术,欢迎各位同仁一起沟通交流。


想了解更多转转公司的业务实践,点击关注下方的公众号吧!



前端 · 目录
上一篇圈复杂度在转转前端质量体系中的应用
继续滑动看下一个
大转转FE
向上滑动看下一个