泛前端代码覆盖率探索之路

背景

通常我们的需求分为提测需求和免测需求,但无论哪种方式,研发自测一直是研发流程中不可或缺的一环。我们团队因研发自测不充分,自2024年起至今,已有3例这样的事故。

代码覆盖率

代码覆盖率是指代码的执行情况统计,帮助我们了解那些代码已经被测试覆盖,哪些还没有。它是衡量测试质量的重要手段。通常分为增量覆盖率和全量覆盖率。目前业界比较成熟的方案是使用babel-plugin-istanbul这个工具来进行插桩统计,假如你有以下代码:

js 复制代码
function add(a, b) {
  return a + b; // ✅ 语句 + 函数
}

function isEven(num) {
  if (num % 2 === 0) {
    return true; // ✅ 分支(if 的 true 分支)
  } else {
    return false; // ✅ 分支(if 的 false 分支)
  }
}

function max(a, b) {
  return a > b ? a : b; // ✅ 三元表达式也是分支
}

function doNothing() {
  // ✅ 没被调用时,函数覆盖率检测不到
  console.log("I do nothing");
}

console.log(add(1, 2))
console.log(isEven(2))
console.log(isEven(3))
console.log(max(10, 20))
console.log(max(30, 20))

配置好babel.config.js:

js 复制代码
module.exports = {
  plugins: [
    [
      'istanbul',
      {
        // 是否使用内联sourceMap
        useInlineSourceMaps: false,
        // 填入需要获取覆盖率的文件后缀,注意带'.'
        extension: ['.js', '.ts', '.vue'] // jsx,ts,tsx等
      }
    ]
  ]
}

安装好@babel/cli、babel-plugin-istanbul后执行:

bash 复制代码
babel index.js --out-dir dist

那么编译后会在输出代码里插入覆盖率统计数据:

以及插桩代码:

其中:

字段 含义 示例
statementMap & s statementMap :每个语句在源码中的位置信息(start 和 end) s:每个语句执行的次数,对应 statementMap 的 key
fnMap & f fnMap :每个函数的定义位置和名称 f:函数被调用的次数,对应 fnMap 的 key
branchMap & b branchMap : 所有的分支结构(如 if, switch, 三元表达式等) b: 每个分支路径被执行的次数(数组,表示每条路径的命中数)

istanbul插桩

无论你的项目是基于Webpack构建还是基于Vite、Rollup构建,都能通过配置babel.config.js来实现代码插桩。也就是配置上述babel-plugin-istanbul插件。

已有问题

代码行列偏移

以Webpack构建为例,这种插桩方式实际上是基于构建后的代码来插桩的,由于babel处理代码往往是最后一步,因此babel拿到的代码是经过各种loader处理后的非原始代码 。他的插桩行列号采集的是编译后的代码的行列号:

插桩的行列信息是基于babel-loader拿到的代码而言的,因为拿到的不是未处理的源码,所以上传的行列号是ts-loader编译后的代码的插桩信息。

因此我们就会看到很多同学说上报的覆盖率数据不正确,行列对应不上。

插桩体积影响

目前业内主流小程序平台都对小程序的代码包设置了严格的体积限制,微信是单包 2MB,总包 16MB,支付宝是单包 2MB,总包 8MB ;包体积作为有限的资源,在小程序业务开发中异常重要,特别对于像滴滴出行这样的大型复杂业务。------包体积分析

插桩必然伴随着体积的增长,而且增长的体积基本在代码体积的50%以上。在小程序的场景里,这种体积增长是完全不可接受的,这会导致小程序无法正常上传、预览。

解决方案

sourcemap溯源

对于行列偏移 而言,正常情况下只要开启项目的sourcemap,比如Webpack中设置devtool: 'source-map'等,那么在打包产物里就能包含各个链路采集的sourcemap。那么通过nyc这个工具就能生成准确的覆盖率报告。

以我们的例子为例,在项目根目录配置好.nycrc

json 复制代码
{
  "include": [
    "./**/*.{js,ts,vue}"
  ],
  "excludeAfterRemap": false,
  "exclude": [
    "tests"
  ],
  "extension": [
    ".js",
    ".vue",
    ".mpx",
    ".ts"
  ],
  "report-dir": "./coverage",
  "temp-directory": "./.nyc_output"
}

安装好nyc

bash 复制代码
npm install nyc -D

配置好npm script

json 复制代码
{
  "scripts": {
    "report": "nyc report --reporter=html"
  }
}

在项目根目录生成.nyc_output文件夹,并把dist/index.js的代码拷贝到浏览器console控制台运行,拿到对应的覆盖率数据coverageData,然后在.nyc_output目录下生成一个cov.json文件:

接着执行npm run report就能生成对应的覆盖率数据:

index.html在浏览器打开就能查看对应的覆盖率报告:

但在我们的实际项目中,这种方式存在两个主要问题:

  1. 在整个loader链路的每一环里都有可能丢失sourcemap
  2. 我们的覆盖率数据往往需要自定义分析,nyc的方式可能并不适合

不同于rollup,对于webpack loader来说,每一个自定义的loader都需要自己合并来自上游的sourcemap,并且将自己对于代码的转换过程中生成的新的sourcemap,传递给下游。由于loader的开发者着重点不一样,可能有些同学的目的在于完成功能,而忽略了sourcemap的处理,那么sourcempa就会在这个loader处断裂,导致代码的行列号溯源止步于此 。那么nyc等工具也无法正常展示代码的行列数据信息。

为此针对webpack,我开发了coverage-source-map-trace-plugin,旨在溯源覆盖率的原始行列数据。详情见:babel-plugin-istanbul如何正确处理Vue文件?

插桩数据压缩

在上面的示例代码里,每次都会执行cov_t1f7p358n()来获取覆盖率对象,这其实会增加输出代码的长度。我们完全可以把其中的:

js 复制代码
cov_t1f7p358n().f
cov_t1f7p358n().b
cov_t1f7p358n().s

提前存储到局部变量里,存储为:

js 复制代码
var f = cov_t1f7p358n().f
var b = cov_t1f7p358n().b
var s = cov_t1f7p358n().s

那么针对其中的isEven,就可以简化为:

js 复制代码
var s = cov_t1f7p358n().s
var b = cov_t1f7p358n().b
var f = cov_t1f7p358n().f
function isEven(num) {
  f[1]++;
  s[1]++;
  if (num % 2 === 0) {
    b[0][0]++;
    s[2]++;
    return true; // ✅ 分支(if 的 true 分支)
  } else {
    b[0][1]++;
    s[3]++;
    return false; // ✅ 分支(if 的 false 分支)
  }
}

对应的dist/index.js的体积表现如下:

体积从6717 byte 减少为了6491 byte ,减少约3.3%

覆盖率数据上传gift

gift是一款由基础架构部开发的对象存储服务,它提供了标准的对象存储,并集成了与存储相关的多项功能。

从上图可以看到,插桩数据压缩实际上只是杯水车薪,根据示例代码,占大头的还有所声明的cov_t1f7p358n这个函数:

这个函数包含了对当前模块儿(文件)中所有的分支、语句、函数的位置以及执行情况的记录,随着当前模块儿的代码行的增多而增多,而且逻辑越复杂,如分支条件越多,函数定义越多等,都会导致这部分产出的体积增大。

仔细观察这部分函数的定义,我们可以发现函数的返回值其实是一个静态对象,因此可以考虑把这部分数据抽离出来,作为静态资源引入,在代码运行时从远程获取这部分数据来更新,从而减少构建产物的体积。

具体方案是二次开发babel-plugin-istanbul插件,将这部分代码转换为JSON存储到本地,再通过自定义插件监听 webpack compiler.done 事件,将所有生成的覆盖率文件上传至gift。具体流程如下:

  1. 经过webpack,将.ts、.vue、.jsx等文件转换成js代码
  2. 将js代码输入babel-loader,转换为ast,同时产出包含coverageData的ast
  3. 将包含coverageData的ast转换为json存储到临时目录
  4. 构建完成后将临时目录下的所有覆盖率数据上传至gift
  5. 代码运行时从远程拉取覆盖率数据到本地进行更新

覆盖率数据拉取与更新

虽然我们将数据上传至gift了,但是这部分数据获取变成了异步,异步获取数据后该如何记录之前同步代码(已经运行了的)已经采集的分支、函数、语句的计数信息? 一个解决方案是通过 Proxy代理计数器的读取和写入,在读的时候进行参数的存储,在写的时候进行覆盖率数据的合并写入:

js 复制代码
global.__gICD = function(options) {
  var s = []
  var f = []
  var b = []
  var proxy = function(k) {
    var params = []
    return new Proxy({}, {
      get(target, key, receiver) {
        if (typeof key !== 'string') {
          return function() {}
        }
        params.push(key)
        return receiver
      },
      set() {
        var [a, b] = params
        if (b !== undefined) {
          k[a] = k[a] || [0, 0]
          k[a][b] = k[a][b] || 0
          k[a][b] += 1
        } else {
          k[a] = k[a] || 0
          k[a] += 1
        }
        if (options.cb) {
          options.cb(params)
        }
        params.length = 0
        return true
      }
    })
  }
  return {
    proxy: {
      s: proxy(s),
      f: proxy(f),
      b: proxy(b)
    },
    s,
    f,
    b
  }
}

比如:b[1][1]++

通过getter先收集两层数据访问存储到params,然后 ++ 操作会访问setter,这时通知外部更新覆盖率数据(执行 options.cb ,下面第13行代码):

js 复制代码
var COV_NAME = (function() {
  GLOBAL_COVERAGE_TEMPLATE
  var options = {}
  var { proxy: COV_NAME, s, f, b } = global.__gICD(options)
  fetch('https://xxx/__coverage__/cov_11onkndtrq.json')
    .then(function(data){ return data.json() })
    .then(function (res) {
      var path = PATH;
      GLOBAL_COVERAGE_TEMPLATE
      var gcv = GLOBAL_COVERAGE_VAR;
      var coverage = global[gcv] || (global[gcv] = {});
      coverage[path] = res;
      options.cb = function() { // 更新覆盖率数据 👈👈👈
        var a = Object.assign
        a(res.s, s)
        a(res.f, f)
        a(res.b, b)
      }
      options.cb()
  })
  return COV_NAME
})()

这样做之后就能正常收集代码的执行情况了。

代码增量插桩

默认情况下babel-plugin-istanbul会根据配置文件对所有满足要求的文件进行插桩,这个插桩量实际上是很大的,即使我们做了插桩数据压缩、覆盖率数据上传gift等优化之后,对于脆弱的微信小程序包体积限制,仍然显得捉襟见肘。

在小程序的日常开发中,是基本不会去动主包代码的,业务逻辑的开发都在分包中进行。因此大部分变更都在各自的分包中进行,我们无须关注全量代码的执行情况。所以在此基础上,我们选择基于 git diff 进行插桩,主要思路为采集用户分支相较于 "origin/master" 分支的改动,收集所有的修改、新增行的信息,然后插桩的时候对这些代码进行过滤,如:

js 复制代码
/**
 * @type {Map<string, Map<string, Array<number>>>}
 */
let diffInfoMap
const getDiffInfoMap = (devBranch, targetBranch = 'master') => {
  if (diffInfoMap) {
    return diffInfoMap
  }
  if (!devBranch || devBranch === '-') {
    return new Map()
  }
  fs.ensureFileSync(GIT_DIFF_STDOUT_FILE)
  execSync(`git diff -U0 $(git merge-base origin/${targetBranch} origin/${devBranch}) origin/${devBranch} > ${GIT_DIFF_STDOUT_FILE}`, {
    encoding: 'utf8',
    stdio: 'inherit', // 让错误能在控制台打印,否则不会抛出
    shell: '/bin/bash' // 指定 shell,确保重定向语法可用
  })
  const diffOutput = fs.readFileSync(GIT_DIFF_STDOUT_FILE, 'utf8')
  const files = parse(diffOutput)
  diffInfoMap = new Map()
  files.forEach(file => {
    const lines = new Set()
    const filePath = path.join(process.cwd(), file.to)
    file.chunks.forEach(chunk => {
      chunk.changes.forEach(change => {
        if (change.add) {
          lines.add(change.ln)
        }
      })
    })
    diffInfoMap.set(filePath, [...lines])
  })
  fs.removeSync(GIT_DIFF_STDOUT_FILE)
  return diffInfoMap
}

然后对编译后的代码进行溯源,找到原始的行列号,并判断该行列号在不在对应文件的改动行里:

js 复制代码
class IncrementalInsert {
  constructor(sourceFilePath) {
    this.sourceFilePath = sourceFilePath
    this.consumers = getSourceFileConsumers(sourceFilePath)
  }

  shouldIgnore(path) {
    const loc = path.node.loc
    if (!loc) {
      return true
    }
    // node_modules下的三方包不用diff
    if (this.sourceFilePath.includes('node_modules')) {
      return false
    }
    const buildenv = getBuildenv()
    const diffInfoMap = getDiffInfoMap(buildenv.branch)
    const originLocForStart = getOriginalPosition(loc.start, this.consumers)
    const originLocForEnd = getOriginalPosition(loc.end, this.consumers)
    const diffInfo = diffInfoMap.get(this.sourceFilePath) || []
    if (diffInfo.includes(originLocForStart.line) || diffInfo.includes(originLocForEnd.line)) {
      return false
    }
    return true
  }
}

最后在babel plugin里过滤掉不包含改动部分的babel ast节点,只保留改动部分,完成对代码的增量插桩。

概要设计

现在就我在我们团队的实践,简单讲一下这块儿我们具体是怎么做的。

系统架构设计

代码目录设计

主要是对istanbuljs进行二次开发,删除了一些不必要的文件。

模块儿功能设计

模块 功能
babel-plugin-istanbul 作为babel plugin,完成代码插桩,调用的是istanbul-lib-instrument的相关能力
istanbul-lib-coverage 负责覆盖率数据的合并
istanbul-lib-instrument 底层插桩工具集,负责代码插桩、覆盖率数据压缩、git diff增量插桩等
istanbul-lib-reporter 负责覆盖率数据的客户端上报
vite-plugin-istanbul vite插桩插件
webpack-plugin-istanbul webpack插件,代码溯源、全局代码注入、覆盖率数据上传gift等

客户端上报支持

目前支持了web/h5、小程序、DRN等。

reporter分为三种上报模式:

  • 手动上报:手动点击按钮进行上报
  • 自动上报:每隔60s自动上报一次
  • 页面hidden上报:页面隐藏时进行上报,包括页面卸载、关闭小程序等(onHide)

在界面上可以看到本次构建的分支、最新提交的commitId,以及master的基准commitId。基于这些信息我们可以在内部平台上找到对应的覆盖率数据。

总结

目前这套方案已在我们部门的各个方向陆续实施,涵盖各类H5、Web、滴滴出行小程序、花小猪打车小程序、以及DRN等跨端场景。总的来说提高了研发信心、降低了测试风险、减少了线上事故,为我们部门的稳定性建设做出了一定的贡献。

相关推荐
浮游本尊3 小时前
React 18.x 学习计划 - 第五天:React状态管理
前端·学习·react.js
-睡到自然醒~3 小时前
[go 面试] 前端请求到后端API的中间件流程解析
前端·中间件·面试
洛卡卡了3 小时前
Sentry 都不想接,这锅还让我背?这xx工作我不要了!
前端·架构
咖啡の猫3 小时前
Vue 实例生命周期
前端·vue.js·okhttp
JNU freshman3 小时前
vue 之 import 的语法
前端·javascript·vue.js
剑亦未配妥3 小时前
Vue 2 响应式系统常见问题与解决方案(包含_demo以下划线开头命名的变量导致响应式丢失问题)
前端·javascript·vue.js
爱吃的强哥3 小时前
Vue2 封装二维码弹窗组件
javascript·vue.js
凉柚ˇ3 小时前
Vue图片压缩方案
前端·javascript·vue.js
慧一居士3 小时前
vue 中 directive 作用,使用场景和使用示例
前端