代码覆盖率平台之实现开箱即用的上报插件

924 阅读16分钟

前言

随着需求迭代越来越快,为了量化测试结果以及提高前端项目上线质量,搭建了代码覆盖率平台作为参考依据之一。在该平台中,代码覆盖率的核心用户功能之一是集成上报插件,完成代码插桩、上报数据的能力。由于用户的项目的多样性和复杂性,我们的设计重点在于广泛兼容各种 bundler API 以及 edge case,确保接入的前端项目,都能准确无误地上报所有必要的覆盖率信息。

本文聚焦于阐述如何构建一款开箱即用的上报插件以及期间解决的问题,旨在为开发者提供即时可用的工具,简化覆盖率数据收集流程。鉴于篇幅与主题的限制,文中并未全面描绘整个覆盖率平台的全貌,对此可能带来的信息不完整性,我深表歉意。

名词解释

  • Bundler:打包器,是一种用于将多个源代码文件组合成一个或几个优化后的输出文件的工具。常见的前端 Bundler工具包括 Webpack、Vite、Parcel、esbuild

  • 代码覆盖率:衡量测试覆盖程度的一种方法,它可以评估代码中被测试覆盖的部分与总代码量(或本次迭代的增量代码)的比例。具体来说,又可以分为“语句覆盖”“行覆盖”“分支覆盖”“函数覆盖”。

  • 四大覆盖率指标:“语句覆盖”“行覆盖”“分支覆盖”“函数覆盖”

  • PluginName: 在文章中会将真实的插件名统一更换为 PluginName

目标

  1. 用户接入插件成本低
  2. 完成代码插桩
  3. 覆盖率收集与上报
  4. 上报数据数据结构规范化,收敛上下文数据与覆盖率数据
  5. 多种上报时机(主动/被动),较低的数据丢失率
  6. 小程序适配 (后续规划)

实现方案

架构概述

在当下的公司业务场景下,用户的前端页面绝大部分都是 Web 框架 + Bundler 组成的,因此我们在第一个版本中只需要提供 Web 项目的集成方案。 从这个场景下出发,前端项目在代码插桩上主要分为两个实现方向:

  1. 代码编译时插桩。代表插件:babel-plugin-istanbul 、c8
  2. 代码运行时插桩。代表搭建:istanbul-middleware

在代码编译时插桩实现中,babel-plugin-istanbul 是通过修改 AST ,插入运行时对象,通过计数器的方式记录四大覆盖率指标;而 c8 则是在将计数器添加到生成源代码的字节码中,字节码中引入计数器不会像修改 AST 那样引起性能问题。 我们调研了业界上相关的实现,总结出以下表格:

平台方式可行性
猫眼1. 编译时插桩
2. 定时上报、手动上报
3. script 嵌入
4. 上报时机:页面刷新、关闭事件监听、控制台手动上报
canyon1. 编译时插桩
2. chrome 插件,手动上报与定时上报
FFEcoverage1. 编译时插桩
2. 定时上报 (5 分钟),需要页面 visible 状态下
3. 离开页面上报
有赞1. web 端编译时插桩
2. node 运行时插桩
3. chrome 插件
马可1. web 端 Visibilitychange ,小程序 onShow、onHide
2. 需要获取覆盖率信息和项目信息

由此可见,在 Web 项目当中,业界采用的都是编译时插桩方案。上报方案主要归为两种:一种是注入运行时代码,另一种是通过 chrome 插件。经过通用性与便捷性考虑,我们采用编译时插桩方案 + 注入运行时代码实现我们的插件。

技术选型

插件设计

因为我们需要兼容不同项目所选择的 Bundler 以及 Bundler 版本之间不同带来的差异, 因此插件采用 unplugin 作为底座,上层实现不同 Bundler的 API 。 插件参数:

/**
 * 用户配置
 */
export interface PluginNameOptions {
  /** 关闭插件功能
    * 默认线上环境关闭,无法开启。
    * 优先级 线上环境判断 > 用户配置 disabled > 默认 ship 环境识别
  */
  disabled?: boolean
  /**
    * 自动上报时间间隔,默认 3 分钟
    * @default 3 * 60 * 1000
    */
  autoReportInterval?: number
  // 以下为 nyc 配置
  /**
   * 需要扫描文件/目录,支持 glob,使用 minimatch 匹配 glob
   * @default '**\/*'
   */
  include?: string | string[]
  /**
   * 需要排除的文件/目录,支持 glob,使用 minimatch 匹配 glob
   */
  exclude?: string | string[]
  /**
   * 需要扫描的扩展名
   * @default ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.vue']
   */
  extension?: string | string[]
  /**
   * 是否扫描包含 node_modules 的文件/目录
   */
  excludeNodeModules?: boolean
  /** 当前工作目录 */
  cwd?: string
}

插件使用: image.png

部分兼容代码: Webpack5 / Webpack4 兼容

2.png

代码插桩

由于 v8 内置的覆盖率目前存在以下问题:

  1. 采用的是字节码形式注入,所以非 chromium 的浏览器是无法使用的(如火狐)。
  2. 浏览器端无法直接获取覆盖率数据。只能通过 playwright、CDP 等方式获取。 所以我们采取比较稳妥的 istanbul 对源代码完成插桩。

截屏2024-07-22 18.09.54.png

对比图来自于monocart-coverage-reports

使用 istanbul 进行插桩,无论是 nyc 还是 babel-plugin-istanbul本质上调用的都是调用 istanbul-lib-instrument

  • nyc:是在本地对编译后的文件进行手动插桩,而且底层也是调用 babeltransform 方法完成插桩,是一个 file to file 的过程,有更多的文件 I/O 开销。
  • babel-plugin-istanbul :babel 插件,接入成本低,将在构建过程中完成代码插桩。

因此我们选择 babel-plugin-istanbul作为我们底层的插桩能力。有了插桩能力以后,是否就直接把 babel插件抛向用户了呢?至少在我调研的范围内,业界都是这样做的。但是我认真思考后,会存在以下几个方面的问题:

  1. 我们有额外的上下文信息需要注入,如 projectId、branch 等构建信息,用户需要配置多个 babel插件才能完成。而每一个 Bundler配置babel的位置未必都在 babel.config.js ,如: Vite是可以额外引入vite-plugin-babel配置的,而 Webpack是需要先引入babel-loader的,用户心智负担可能会增高,最终导致未接入就先劝退了。
  2. 用户可能不使用 babel,可能使用 swc 。
  3. 插件行为不可控,后续拓展/修复功能无法收敛。

以上问题都让我决定在 babel-plugin-istanbul之上增加一层兼容层,我们需要实现的核心功能是在不同框架中完成代码插桩与兼容处理。

  1. Webpack 在 babel-loader注入 babel-plugin-istanbul;如果没有使用 babel-loader则增加babel-loader
  2. Vite 直接调用 istanbul-lib-instrument,具体实现大量参考了:vite-plugin-istanbul

入口代码如下:

/**
 * 代码插桩
 * @param options
 * @returns
 */
export const unpluginIstanbul: UnpluginFactory<PluginNameOptions | undefined, false> = options => ({
  name: 'unplugin-plugin-name:istanbul',
  enforce: 'post',
  webpack: instrumentWebpackPlugin,
  vite: innerViteIstanbul(options),
});

/**
 * 注入插桩 sdk 至 html head
 * @param options
 * @returns
 */
const unpluginInject: UnpluginFactory<PluginNameOptions | undefined, false> = options => ({
  name: 'unplugin-plugin-name:inject',
  enforce: 'post',
  webpack: injectHtmlWebpackPlugin(options as WebpackPluginNameOptions),
  vite: viteInjectHtmlPlugin(options),
});

export const unpluginFactory: UnpluginFactory<PluginNameOptions | undefined> = (options, meta) => {
  // 省略逻辑...
  return [
    unpluginIstanbul(options, meta),
    unpluginInject(options, meta),
  ];
};

export const unplugin = /* #__PURE__ */ createUnplugin(unpluginFactory);

数据上报

在成功完成插桩后,我们的插桩数据会以全局变量 window.__PluginNameCoverage__保存在页面中,运行时实时改变插桩数据。除此以外,我们还需要一份构建时的上下文信息跟随插桩数据一起上报,我们将这些信息称为 meta data,这些 meta data 用于标识项目、commitId、分支等,给予服务端分析与后续平台侧展示使用。 最终注入的全局变量有以下内容:

interface Window {

  __PluginNameCoverage__?: Record<string, any>

  __PluginName__?: {
    /** 项目名 */
    projectName: string
    /** 分支名 */
    branch: string
    commitId: string
    /** 镜像构建时间 */
    buildTime: number
    /** 当前 commitId 的提交人 */
    userName: string
    nycConfig: {
      /**
       * 需要扫描文件/目录,支持 glob,使用 minimatch 匹配 glob
       * @default '**\/*'
       */
      include?: string | string[]
      /**
       * 需要排除的文件/目录,支持 glob,使用 minimatch 匹配 glob
       */
      exclude?: string | string[]
      /**
       * 需要扫描的扩展名
       * @default ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.vue']
       */
      extension?: string | string[]
      /**
       * 是否扫描包含 node_modules 的文件/目录
       */
      excludeNodeModules?: boolean
      cwd?: string
    }
  }
}

设计好好变量的注入后,我们想要收集覆盖率数据给服务端完成分析,需要在浏览器侧完成上报,为此开发了一个 reporter的上报 SDK。我们将上报时机分为两方面,一方面是用户主动型、一方面是用户被动型。

  • 用户主动型
    • 用户通过注入的特定 UI 按钮,点击后完成上报
    • 在控制台调用特定的全局函数,完成上报
  • 用户被动型
    • 用户离开页面,完成上报
    • 页面为 visible状态,3 分钟内定时上报
    • 恢复网络连接时上报

实现了 reporter SDK 以后,我们只需要将其以 script 的方式注入到用户的页面当中即可。大致实现如下:

async function injectSDKScriptTag(html: string, head: HtmlTagObject[], body: HtmlTagObject[], options?: WebpackPluginNameOptions) {
  debug(`正在替换 html, 原 html: ${html}`);

  const resolvedHead = [...head];

  resolvedHead.push({
    tagName: 'script',
    voidTag: false,
    attributes: {
      type: 'text/javascript',
      defer: 'defer',
    },
    innerHTML: reporterSDKCode,
  });

  resolvedHead.push({
    tagName: 'script',
    voidTag: false,
    attributes: {
      type: 'text/javascript',
      defer: 'defer',
    },
    innerHTML: `${await genMetaCode(options)}\n${genReporterCode(options)}`,
  });

  const resolvedBody = [...body];

  resolvedBody.push({
    tagName: 'script',
    voidTag: false,
    attributes: {
      type: 'text/javascript',
    },
    innerHTML: `${manualReportUICode}`,
  });

  return {
    resolveHtml: html,
    resolvedHead,
    resolvedBody,
  };
}

从上到下注入的 script 分别是:

  1. SDK源代码
  2. 初始化 meta data;初始化 reporter SDK。

meta data 主要通过构建时 CI 环境获取的 git 信息:

4.png 这里因为公司架构原因以及为了保证本地环境可用性,修改为:

  • 本地环境:使用 git 命令获取信息
  • 测试环境:使用 CI 环境 + api 调用获取信息

截屏2024-07-22 20.36.27.png

  1. 注入上报图标 JS、CSS 代码。这是为了主动上报而做的一个小图标,支持持久化拖拽位置保存能力。

截屏2024-07-22 20.44.18.png 至此,基本的流程已经完成了,整体看起来挺简单,其实难点都在真实项目接入后出现的各种 edge case。

难点突破

接入后未显示主动上报浮窗

目前我们对外承诺的接入成功标志是在页面上会显示上报按钮。在调试过程中有出现过 monorepo项目接入后无上报按钮。经过排查后发现时因为项目中存在多个版本的 html-webpack-plugin,导致调用 htmlWebpackPlugin.getHooks 时找到的不是当前实例,最终未注入代码而又不报错。 解决思路:增加针对 Webpack的特别配置,让用户将 html-webpack-plugin这个插件直接传入,我们的插件内优先使用用户的传入的插件。

7.png

webpack + ts + vue 项目下出现覆盖率的行错乱

8.png 这个问题核心是 ts-loader 对 sourcemap 未处理或者无 sourcemap ,导致传递给下一个 loader 后出现行偏移。 针对不同的情况我们有不同的解决办法:

  1. 无 sourcemap

需要用户在tsconfig.json中开启 sourceMap配置:

{
  "compilerOptions": {
    "sourceMap": true,
  },
}
  1. 用户的 ts-loader < 9.5

经过排查,因为在 ts-loader < 9.5 的版本中未将 sourcemap 处理完成透传到下一个 loader 中,导致与源文件无法对应。而绝大部分的 Vue CLI 项目都使用的是不符合预期的 ts-loader 版本,为此我们实现了 ts-loader版本替换能力,执行插件时会替换用户的 ts-loader

  const overrideTsLoader = (loaders: LoaderItem[], isWebpack5: boolean) => {
    loaders.forEach((loader) => {
      if (loader.loader.includes('ts-loader')) {
        // 获取特定 ts-loader 编译后产物的路径
        loader.loader = getCompiledTsLoaderPath(isWebpack5);
        debug('ts-loader config:', loader);
      }
    });
  };

在替换前因为 ts-loaderWebpack版本有捆绑关系,Webpack5 对应 ts-loade@9.xWebpack4 对应 ts-loader@8.x 及以下。而 ts-loader@8.x 并没有将处理 sourcemap 的 PR 进行 backport,因此我针对这种情况fork 了 ts-loader 并且重新编译了一份处理过 sourcemap 的补丁版本。 针对 ts-loader@9.x我们利用 prebundlets-loader@9.5.1 进行了预编译。所以整体的处理方式是:

场景处理逻辑
Webpack5prebundle 预编译,直接替换为 ts-loader@9.5.1
Webpack4本地编译,然后直接替换为补丁版本

数据中出现异常文件路径名

在解决上一个问题后,其实我还没意识到 webpack loader 不同版本之间兼容问题的严重性,直至这个问题出现。在接入过程中我们遇到过覆盖率数据的 inputSourceMap.sources 中存在以下几种奇怪的路径名:

  1. loader 未处理的: /project/vue-loader/dist/index.js??ruleSet[0].use[0]!/project/xxx/app.vue?vue&type=script&lang=ts
  2. 相对路径名:app/xxx/xxx.vue

我们想要的文件名:/app/xxx/xxx.vue。 为什么我们需要的是绝对路径呢?因为在 istanbul的内部处理中,会在remapCoverage过程中使用到对应的路径,如果路径不符合预期,最终的数据上就会出现一个文件对应两个路径名 、相对路径等情况。因此我们首先将我们需要的绝对路径以元数据的方式注入到映射原始位置的对象中: image.png 其次,在最终生成 coverageMap 的最后一步时,我们将 loader 未处理的路径过滤掉、将最终 nyc 使用的 path修改为 absPathimage.png 如果你感兴趣,可以看一下整体的 nyc 生成报告的关键函数调用图: image.png

因为这个问题的发生,我们发现在 Webpack中许多旧版本的 loader是没有处理好 sourcemap 的,如:eslint-loader。我们不太可能将每一个 loader 都像修改 ts-loader 那样妥善处理好,为了保证路径名可以与后续 git 文件路径完成匹配,所以我们选择拓展 coverage 对象的字段,保证生成出来的报告中的路径名符合我们预期。

离开页面丢失上报数据

作为保证覆盖率数据的实时性的策略之一,需要在用户离开页面时进行上报数据请求。在我们的场景中采用 sendBeacon完成,但是在实际应用时会存在上报数据未发送的情况,表现形式是这个请求一直处于 pending 状态。 image.png 根据这个问题我阅读了fetch 规范文档,里面提到两点: image.png image.png

  1. sendBeacon底层实现与配置了keepalivefetch是一样的,sendBeacon的实现中也配置了 keepalive,让他在页面离开时可以发送数据。(这个在我查阅 chromium 实现 sendBeacon的源码也看到了)
  2. 正在保持 keepalive 的请求大小与待发送请求的 contentLength 总和如果超过 64kb,是会报 network error 的。

结合规范我得知了 sendBeacon是存在 contentLength的 64kb 限制的,这个限制是在 User Agent侧限制的,我们常见的场景就是浏览器;待发送请求的 contentLength总和不能够超过 64kb,也就是说我们将请求体切片后再发送也是不可行的。 随后我查看了我们的请求体大小,确实是比 64kb 要大,一般的项目也会在 64kb 以上,会随着项目文件大小而增大。此时我思考了两个可行的方向:

  1. 减少请求体体积
  2. 改变上报的时机

请求体的体积 95% 都在覆盖率数据中,覆盖率数据格式大致如下: image.png

  • path:文件路径,绝对路径
  • statementMap:标记该文件所有语句的 Map,fnMap,branchMap 同理
  • s:每条语句的覆盖率命中次数,0 表示未命中
  • inputSourceMap:sourcemap 的 V3 格式

inputSourceMap 和 hash 是可以在编译过程中确认不变的我们可以删掉,其中占用最大体积的是 inputSourceMap,这个对象包含:

  • version:此映射遵循的源映射规范版本。
  • sources:原始源文件的 URL 数组。
  • names:可由单个映射引用的标识符数组。
  • sourceRoot:可选。所有源都是相对来源的 URL 根。
  • sourcesContent:可选。原始源文件的内容数组。
  • mappings:包含实际映射的 base64 VLQ 字符串。
  • file:可选。与此源映射关联的生成文件名。

按照方向一我们可以选择在构建时将 inputSourceMap属性的内容提前发送到服务端,上报后再由服务端找到对应上报数据 path 的 inputSourceMap并且与组装到一起后再生成报告。实施这个方案需要修改 istanbul-lib-instrument/src/vistor.js,将 inputSourceMap 排除在上报数据外。最终因为可能删除后也可能超出 64kb 限制、实施难度与离开页面场景的应用频率我们没有选择它。

方向二大体思路则是在用户离开页面时将上报数据保存在 localStorage中,下次进入同源的页面时,将保存的数据上报上去,流程图如下: image.png 上述流程中唯一的前提是我们的项目都接入了插件。在整个测试流程中,下一个页面与前一个页面是同源的几率是很高的,可能一个业务团队使用的都是一个域名,因此这个方案的 ROI 与确定性更高,所以我们选择了它。

成果

只展示该插件带来的成果,不包括覆盖率平台整体成果

  1. 插件支持使用了不同的 Bundler 项目, Webpack4/5、Vite 接入后确保行为一致。
  2. 用户首次接入成本控制在 10 分钟内(包含看文档,写代码),多项目二次接入可降低到 1 分钟内。

16.png

未来规划

随着我们的覆盖率插件初步功能的成功上线,我们认识到仍有大量的潜在领域等待探索与优化,旨在更好地服务于各类业务场景。以下是我们的后续发展计划,旨在构建一个更全面的覆盖率分析平台的解决方案:

  1. 支持多元化的 Bundler 工具,如:rspack
  2. 支持 CSS 覆盖率可视化,高效帮助删除遗留的无用代码
  3. 目前只支持有 Web 页面的项目,后续会完善对 SDK 类型的前端项目的支持
  4. 跨平台项目支持插桩上报。例如:小程序、Node 项目
  5. 支持不同语言的项目完成覆盖率上报。例如:go

结语

本篇是我的第一篇文章,欢迎在评论区互相讨论。如果你觉得这篇文章对你有帮助,欢迎关注我的掘金账号以及个人公众号:Label 也是小猪呀

扫码_搜索联合传播样式-标准色版.png

相关资料