重新审视 JavaScript 代码覆盖率

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第10天,点击查看活动详情

原味地址:Rethinking JavaScript Test Coverage

本文出自 Benjamin Coe,他是 npm 的产品经理,同时也是 yargs、istanbul 的核心维护人员。

大家好,我是码农小余。最近在几个项目中使用 vitest 替换 jest,在测试覆盖率这一块提升得比较大。于是就去查资料了解 vitest 与 jest 测试覆盖率的区别时,看到一些有趣的博文。本文先是通过编译埋点以及基于 V8 的代码覆盖率两种方式优缺点,然后讲述了 Benjamin Coe 在代码覆盖率上的追寻。

现在可以通过 Node.js 设置环境变量 NODE_V8_COVERAGE 指向一个目录去输出代码覆盖率。c8 工具可以通过覆盖数据去生成漂亮的报告信息。

测试覆盖率的历史

在 JavaScript 代码中,历史上都是通过巧妙的 hack 来进行搜集。类似 Istanbulblanket 这样的工具,通过解析 JavaScript 代码然后埋点进行收集,这样不会影响原始逻辑。举个例子:

function foo (a) {
  if (a) {
  // do something with 'a'.
  } else {
  // do something else.
  }
}
复制代码

改写成:

function foo(a) {
  cov_2mofekog2n.f[0]++;
  cov_2mofekog2n.s[0]++;
  if (a) {
    // do something with 'a'.
    cov_2mofekog2n.b[0][0]++;
  } else {
    // do something else.
    cov_2mofekog2n.b[0][1]++;
  }
}
复制代码

cov_2mofekog2n.f[0]++ 预示着 foo 函数被执行了,cov_2mofekog2n.s[0]++ 表示调用了此函数中的语句,cov_2mofekog2n.b[0][0]++cov_2mofekog2n.b[0][1]++ 预示着分支被执行了。基于以上这些数据,报告就生成了。

上述方式有效,但也有一些缺点:

  • 类似 istanbul 这样的工具需要跟上不断发展的 JavaScript 语言,经常会出现跟不上语言特性不同步的情况,比如这个 issue 就是不同步导致的;
  • 在程序每一行都埋点会显著地影响性能;
  • 不在特意修改代码行为上,部分代码很难被收集,比如 hoist statement counter for class variables

我希望能够有一种更好的方式收集代码覆盖率……

V8 中的代码覆盖率

在 Node.js 支持 ESM 后,istanbul 出现了问题。Bradley 重写了 Node.js 的加载机制去支持 ESM 导致不再支持 require 的钩子,这导致 istanbul 很难检测到 ESM 模块已被加载并对其进行检测。我提出这个问题之后,Bradley 给出了另一个建议:

如果利用 V8 新的内置覆盖功能会怎样?

使用直接内置在 V8 引擎中的覆盖可以解决基于转译的代码覆盖方法所面临的许多缺点。好处是:

  • V8 没有使用计数器来检测源代码,而是将计数器添加到从源代码生成的字节码中。这使得计数器改变程序行为的可能性大大降低;
  • 字节码中引入的计数器不会像在源代码的每一行中注入计数器那样对性能产生负面影响;
  • 一旦新的语言特性被添加到 V8 中,它们就会立即被覆盖。

我开始研究使用 Node.js 的 inspector 模块直接从 V8 收集覆盖率;有一些小问题:

  • inspector 的时间问题使得只能检测函数的覆盖率(无法收集块级语句的覆盖率:if 语句、while 语句、switch 语句);
  • 块覆盖缺少一些功能:|| 表达式,&& 表达式;
  • 让 inspector 启动并运行的步骤过于复杂。您需要启动您的程序,启用 inspector,连接到它,然后转覆盖率报告。

撇开这些挑战不谈,通过 inspector 使用 V8 的覆盖范围感觉很有希望。

证明想法

我联系了 V8 团队的 Jakob Gruber ,就我看到的将 V8 覆盖率与 Node.js 集成的错误联系起来。谷歌的人们也很高兴看到 Node.js 中的覆盖支持,并立即着手解决这个问题。

在与几位 V8 维护人员讨论后,我们确定实际上存在一种启用块级覆盖的机制:

  • 需要使用 --inspect-brk 标志启动程序,以便 inspector 立即终止执行;
  • 需要启用覆盖范围;
  • 需要运行 Runtime.runIfWaitingForDebugger 来启动程序执行;
  • 需要监听事件 Runtime.executionContextDestroyed,此时可以输出覆盖率。

我测试了上述方法,它奏效了!

接下来我问 Jakob 是否可以参与并开始在 V8 中实现一些缺失的覆盖功能。在 V8 团队几个人的耐心帮助下,我实现了对 ||&& 表达式的支持。

此时,我们已经输出了详细的 V8 覆盖率信息,但没有简单的方法来输出人类可读的报告。编写了两个 npm 模块来促进这一点:

  • v8-to-istanbul,它将 V8 格式覆盖输出转换为 istanbul 格式。
  • c8,它将整个 inspector 步骤整合到一个命令中,因此您可以通过简单地运行 c8 node foo.js 来收集覆盖率。

利用这些新库,我们终于能够看到覆盖率报告!

这是一个激动人心的里程碑,但我仍然不满意。原因如下:

  • inspector 步骤继续变得复杂;
  • 根据程序退出的方式,例如,如果 process.exit(0) 被调用,则无法转储覆盖率报告;
  • 我们使用的方法要求我们等待 inspector 启动并通过套接字连接到它;这很慢,感觉不雅。

Node 核心实现

我顿悟了,如果可以将 Node.js 隔离在一个总是丢弃覆盖范围的模式中呢?

  • 这意味着另一个进程不需要连接到 inspector 会话并启动覆盖率跟踪;
  • 这将使我们能够更好地检测 Node.js 何时关闭,以便我们可以捕获 process.exit(0) 和 process.kill 事件。

在与 Anna Henningsen 的交谈中,事实证明 Node.js inspector 的实现符合我的想法:

  • inspector 实际上总是在大多数环境中运行,只是未启用 websocket 接口;
  • 有一个可用的内部 inspector 协议可以与 inspector 交互,而无需创建套接字连接。

很兴奋,我确认将 V8 测试覆盖率作为 Node.js 本身的一个特性来实现。这是它的样子:

  • 在 Node.js >=10.10.0 中,您现在可以将环境变量 NODE_V8_COVERAGE 设置为目录,这将导致在此位置输出 V8 覆盖率报告;
  • 工具 c8 现在只需启用 NODE_V8_COVERAGE 环境变量,使用 V8 覆盖数据,并输出漂亮的报告。

如何使用

现在,你可以通过以下几步使用 Node.js 内置的覆盖率报告:

  1. 确保您已升级到 Node.js 10.10.0;
  2. 安装 c8 工具,该工具可用于将 V8 覆盖率输出转换为可读的报告;
  3. 使用 c8 执行您的应用程序,例如 c8 node foo.js。

猜你喜欢

转载自juejin.im/post/7084973940069629965