跳至主内容

javascript-unit-testing-performance

· 10 分钟阅读
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

在 Facebook,Jest 无时无刻不在运行着成千上万的测试,无论是通过持续集成流程还是工程师在开发过程中手动触发。多年来这套机制运行良好,即便当初参与 Jest 开发的成员已转向 Facebook 内部其他项目。

但随着工程师们添加的测试越来越多,我们发现 Jest 的性能将难以持续扩展。此外,过去一年 JavaScript 生态经历了巨大变革,出现了 npm3 和 Babel 等我们未曾预料的新事物。为此我们组建了新的 Jest 团队来解决这些问题,并将通过本博客持续分享我们的进展和规划。

Jest 与多数测试运行器有所不同,我们将其设计成能完美适配 Facebook 的基础设施环境:

  • Monorepo(单体仓库) Facebook 拥有包含所有 JavaScript 代码的巨型单体仓库。这种架构对我们有诸多优势,有位 Google 工程师的精彩演讲详细剖析了单体仓库的利弊。

  • 沙盒隔离 Jest 的另一重要特性是通过虚拟化测试环境并封装 require 来实现代码执行的沙盒隔离,确保测试独立性。我们正致力于将 Jest 模块化,以便在非测试场景也能利用该功能。

  • providesModule 如果您曾查看我们的开源 JavaScript 项目,可能注意到我们使用 @providesModule 标头为模块分配全局唯一 ID。这需要定制工具链支持,但使我们无需相对路径即可引用模块,极大提升了开发速度,随着工程团队扩张仍保持良好扩展性,并促进了全公司代码共享。查看 RelayContainer 可了解实际应用案例。但此方案的缺点是:为解析单个 require 语句,我们不得不读取解析整个 JavaScript 代码库。若没有强大的缓存机制,这对 Jest 这类短生命周期进程显然代价过高。

受这些独特约束影响,Jest 在全量测试场景下可能永远无法达到其他测试运行器的速度。但工程师很少需要运行全量测试。借助 node-haste 项目的静态分析能力,我们将 jest --onlyChanged(简称 jest -o)设为 Facebook 的默认运行模式。该模式通过构建反向依赖图,仅运行受代码变更影响的测试。

测试任务的最优调度

多数情况下,静态分析会判定需要运行多个测试,受影响测试数量从几个到上千不等。为加速该过程,Jest 通过多工作进程并行执行测试。这非常契合 Facebook 的远程开发环境——服务器通常配备多核 CPU。

近期我们发现 Jest 常在运行尾声卡在_“等待最后 3 项测试”_状态长达一分钟。问题根源在于代码库中存在少数极慢的测试拖累了整体运行时间。在优化这些慢测试的同时,我们还改进了 Jest 的任务调度策略。过去我们基于文件系统遍历的随机顺序调度任务,下图展示了两个工作进程上 11 项测试的调度情况(灰色区块大小代表测试耗时):

性能基础调度

随机调度导致快慢测试混合执行,当最慢的测试被分配到所有其他测试几乎完成时才运行,期间另一个工作进程处于空闲状态。

我们改为根据测试文件大小进行调度,这通常是预估测试耗时的有效指标。数千行代码的测试显然比15行代码的测试耗时更长。虽然这种优化使整体测试速度提升了约10%,但我们随后发现了更优方案:Jest现在会将每个测试的运行时间存入缓存,后续运行时优先调度最耗时的测试。这项改进使总体测试运行效率提升了约20%。

以下是相同测试集采用优化调度后的效果示例:

优化调度示例

由于优先执行耗时测试,Jest有时会显得启动缓慢——我们只在首个测试完成后才显示结果。未来我们计划优先运行上次失败的测试,因为让开发者最快获取失败信息才是关键。

内联引入(Inline Requires)与惰性模拟(Lazy Mocking)

如果你曾用Jasmine编写测试,代码可能类似这样:

const sum = require('sum');
describe('sum', () => {
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
});

Jest的特殊机制是在每次测试(即每个it调用)后重置整个模块注册表,确保测试间互不依赖。在Jest之前,测试常存在隐性依赖和模块状态泄漏问题。当工程师删除、重排或重构测试时,部分测试会莫名失败,令人困惑。

Jest中每个测试都会获得全新的模块副本,包括需耗费大量时间生成的模拟依赖项。副作用是我们必须在每个测试前手动调用require

let sum;
describe('sum', () => {
beforeEach(() => {
sum = require('sum');
});
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
it('works too', () => {
// This copy of sum is not the same as in the previous call to `it`.
expect(sum(2, 3)).toBe(5);
});
});

我们开发了名为inline-requires的Babel转换器,它移除顶层的require语句并将其内联到代码中。例如const sum = require('sum');会被移除,而文件中所有sum的引用会被替换为require('sum')。通过这个转换器,我们可以像Jasmine那样编写测试,代码会被转换成这样:

describe('sum', () => {
it('works', () => {
expect(require('sum')(5, 4)).toBe(9);
});
});

内联引入的重要附加效益是:我们只需引入测试实际用到的模块,而非整个文件的所有模块。

这引出了另一项优化:惰性模拟。其核心思想是按需模拟模块,结合内联引入机制,避免了大量模块及其递归依赖的模拟开销。

我们通过codemod工具快速更新了所有测试——这次看似简单的改动涉及50,000行代码。内联引入和惰性模拟使测试运行效率提升50%。

内联引入Babel插件不仅适用于Jest,对常规JavaScript同样有效。Bhuwan一周前将其部署给所有facebook.com用户,显著提升了应用启动速度。

目前若想在Jest中使用此转换器,需手动添加到Babel配置。我们正在研究更简便的启用方式。

仓库同步与缓存机制

Jest开源版本曾是我们内部版本的分支,每隔数月才手动同步一次。这个过程既痛苦又需每次修复大量测试。近期我们升级了Jest,在iOS/Android/Web平台实现功能统一,并启用了自动同步流程。现在Jest在开源仓库的每次变更都会通过所有内部测试,全球只需维护一个统一版本。

在解除分叉后,我们首先利用的功能是预处理器缓存。如果您将 Babel 与 Jest 配合使用,Jest 必须在执行前预处理每个 JavaScript 文件。我们构建了缓存层,使未更改的文件只需转换一次。解除分叉后,我们轻松修复了开源实现并在 Facebook 内部部署。这带来了 50% 的性能提升。由于缓存仅在第二次运行时生效,Jest 的冷启动时间不受影响。

我们还发现解析相对依赖时进行了大量路径操作。由于模块注册表在每次测试时重置,成千上万的调用可以通过记忆化优化。关键优化是增加跨测试文件的缓存:之前我们为每个测试文件生成自动模拟功能的模块元数据。但模块导出的对象永不变化,现在我们在测试文件间共享这些代码。遗憾的是,由于 JavaScript 和 Node.js 没有共享内存,我们必须在每个工作进程至少执行一次这些操作。

质疑一切

优化性能时,必须深入分析系统上下游环节。对于 Jest 而言,这包括 Node.js 和测试文件本身。我们首先将 Facebook 内部使用的 Node.js 从陈旧的 0.10 版本升级至 iojs,再升级到 Node 4。新版 V8 引擎显著提升性能且升级过程顺畅。

我们发现 Node.js 的 path 模块在执行数千次路径操作时性能低下,该问题已在 Node 5.7 修复。在 Facebook 内部完全弃用 Node 4 之前,我们将使用自研的 fastpath 模块替代。

接着我们开始重构过时的 node-haste。如前所述,必须解析整个项目的 @providesModule 标头才能构建依赖图。该系统初建时 node_modules 尚未普及,我们的文件系统爬虫未能正确排除这些目录。

旧版 Jest 会读取 node_modules 内所有文件——这正是启动缓慢的元凶。我们基于 react-native 的打包器重写了整个项目,现在即使大型项目的 Jest 启动时间也缩短至 1 秒内。react-native 团队的 DavidAmjadMartin 在此项目中贡献卓著。

累积成效

上述优化使测试运行时间减少 10% 甚至 50%。所有测试最初耗时约 10 分钟,若无这些改进,现在可能需 20 分钟。而优化后,完整测试套件仅需 1 分 35 秒!

更重要的是,新增测试对总耗时的影响微乎其微。工程师可以自由编写运行更多测试,无需担忧性能代价。

随着 Jest 0.9 版本发布及 node-haste2 集成的优化,Relay 框架测试套件从 60 秒缩短至 25 秒,react-native 测试套件在 13 英寸 MacBook Pro 上不到 10 秒即可完成。

我们对现有成果深感欣喜,并将持续改进 Jest。若您有意贡献,欢迎通过 GitHub、Discord 或 Facebook 联系我们 :)