一个多月前,我们讨论了迁移到 Node.js 测试运行器的可能性。尽管我们对 Mocha 足够满意,但我们一直在寻求加快 CI 任务的速度。
依赖我们运行时内置的测试运行器对我们的主单体仓库有一些优势
- 在我们的单体仓库中,需要安装和维护的依赖项减少了两个:`mocha` 和 `chai`。
- 可维护性:Node.js 项目中有更多人参与维护 Node.js 测试运行器。
- 未来效益:我们相信测试运行器会随着时间推移而改进,最终在我们的 CI 工作流中节省一些时间。
从想法到概念验证 (PoC)
Astro 单体仓库有 500 多个测试套件:包括集成测试和单元测试,我们共有 664 个套件,总计 1603 个测试。这些测试中的大多数是集成测试。
在我们单体仓库中,集成测试意味着创建一个小型的 Astro 项目,使用特定环境(开发、静态生成 (SSG) 或动态生成 (SSR))构建此项目,然后对已构建的页面运行断言。没错,每个集成测试都需要 `vite` 来构建和打包项目。
在决定进行此次迁移之前,我们想确保放弃 Mocha 不是一个错误。尽管 Mocha 有些怪癖,但它是一个非常有能力的测试运行器!它已经存在了很长时间,并且经过了实战考验。如果您使用 Mocha,您会很满意。
PoC 的想法是为了了解
- Node.js CLI 参数的灵活性以及测试报告器可定制的程度。
- 测试套件的执行速度。
- 整体开发者体验。
我们如何开始
我们首先只迁移了一个尚未利用 `astro` 集成套件的包:`create-astro`。这是一个很好的机会来使用内置的断言库 `node:assert`,了解它提供的选项,并评估其与 Mocha 相比的性能。
由于 `create-astro` 只有少数几个测试,因此将其测试文件迁移为使用 `node:test` 和 `node:assert` 而非 `mocha` 和 `chai` 相对容易。之后,唯一剩下的就是将 `mocha` 命令更新为 `node --test` 来执行测试。然而,我们很快在使用 `node --test` 命令时遇到了问题,包括:
- 在传递多个参数时,它在解析 glob 语法方面存在问题(例如 `node --test "test/*.test.js" --test-name-pattern="foo"`)。
- 无法传递 `--test-concurrency` 标志(仅在 Node.js 21 及更高版本中可用),但可以通过使用编程 API 的 `concurrency` 选项来解决。
- 吹毛求疵地说,参数名称冗长:`--test-name-pattern` 而不是 `--match, -m` 参数;`--test-timeout` 而不是 `--timeout, -t` 参数等。
因此,为了解决这些问题,我们创建了一个自定义脚本,可以通过 `astro-scripts test` 命令调用。正如您稍后将看到的,此决定也证明有助于实现更多变通方法。
打开潘多拉的盒子
成功迁移第一个包后,我们尝试迁移 `@astrojs/node` 包的测试套件。这个集成是我们下载量最大的集成之一,因此我们有大量测试。此外,这个包的所有测试都包含集成测试,因此这是一个检查测试运行器性能的好机会。
PR 准备好后,我们注意到 Node.js 测试运行器明显比 Mocha 慢。我们进行了调查,发现 Node.js 为每个测试文件生成一个新进程,以确保每个测试套件都在隔离状态下运行。在隔离状态下运行测试套件通常是一种好习惯,因为它确保测试在一个未受污染的环境中运行。
然而,我们的测试套件本身已经是隔离的。事实上,我们能够使用主线程运行我们的 Mocha 测试套件,而没有遇到任何典型问题:副作用、污染环境等。不幸的是,Node.js 没有提供“在同一线程中运行所有测试”的选项。因此,我们不得不提出一个解决方案来解决测试运行缓慢的问题。(毕竟我们是工程师,我们解决问题!)
使用我们的内部 `astro-scripts test` 命令,我们通过创建一个导入所有测试套件的临时文件来解决这个问题,然后让 Node.js 测试该单个文件。这样,文件只生成一个进程,我们达到了与使用主进程相同的性能水平。
然而,这也有其缺点:如果出现测试失败或超时,我们无法判断是哪个测试导致的问题。这是我们发现的主要怪癖,虽然并非每个团队都会做出这种选择,但我们接受了这种权衡,以获得前面提到的好处。毕竟,我们以前也接受了 Mocha 的怪癖!
Node.js `assert` 和 `chai`
在迁移过程中,我们不得不将 `chai` 库替换为 `node:assert/strict`。这项任务揭示了在使用 `chai` 时,你可以通过不同方式执行相同的检查。例如,你可以通过至少四种不同的方式运行相等性检查
import { expect } from "chai";
expect("foo").to.eq("foo")expect("foo").to.be.eq("foo")expect("foo").to.equal("foo")expect("foo").to.be.equal("foo")
一方面,拥有这种灵活性是好事。但另一方面,测试代码变得不一致。使用 Node.js 断言模块,执行此检查只有一种方法
import { assert } from "node:assert/strict";
assert.equal("foo", "foo")
Node.js 断言模块提供了我们所需的所有功能,因此从 `chai` 的迁移并没有我们想象的那么痛苦。我们对 `chai` 的使用非常少。但是,我们确实想念 `chai` 的 `.includes` 快捷方式
import { expect } from "chai";
expect("It's a fine day").includes("fine")
Node.js 断言模块不提供这样的实用工具,所以我们最终使用相等性断言与 `String#includes` 函数
import assert from "node:assert/strict";
assert.equal("It's a fine day".includes("fine"), true)
龙来了
如前所述,我们有大量的测试文件,并且几乎每天都在添加新的测试。打开一个一次性的 PR 来迁移整个单体仓库是不可行的。这需要一个人做大量工作,而且保持分支更新也会很有压力。
所以我们想出了一个简单的计划
- 首先迁移单体仓库内部的小型包。
- 通过在同一 CI 中同时使用 Mocha 和 Node.js 测试运行器,缓慢迁移主包 `astro`。
- 移除 Mocha。
为了实现这一点,我们向社区寻求了帮助。我们认为这是一个完美的机会,可以让不熟悉 Astro 业务逻辑的人为项目做出贡献。而且,我们可以加快迁移过程。
我们创建并置顶了一个总览问题来协调工作。每位贡献者都负责单个包的迁移,并为每个包打开一个单独的 PR。甚至有两位首次为项目做出贡献的新人也加入了这项工作。看到这一切真是太棒了。在一周内,我们就成功迁移了所有包!
迁移主包 `astro` 是一项壮举!它是迄今为止包含测试数量最多的包。为了缓慢而谨慎地执行此迁移,我们不得不提出一个开箱即用的解决方案。
我们设置了 Node.js 测试运行器,使其仅测试名为 `*.nodetest.js` 的文件。这样做让我们可以在 CI 中继续测试所有文件。剩下的就是通过提供(并记录!)一个清晰的流程供社区遵循来协调我们的社区
- 使用总览问题告知其他贡献者您打算迁移哪些文件;
- 将要迁移的文件从 `*.test.js` 重命名为 `*.nodetest.js`;
- 迁移文件;
- 打开 PR,等待审查,如果成功,Astro 维护者将合并 PR。
在 @log101、@mingjunlu、@VoxelMC、@alexnguyennz、@xMohamd、@shoaibkh4n、@marwan-mohamed12、@at-the-vr 和 @ktym4a 的帮助下,我们在一周内迁移了近 300 个测试套件!
成果
我们对结果相当满意。我们没有发现测试性能有任何明显的退步。Node.js 提供的断言模块包含了我们所需的所有实用工具,并且支持 `describe`/`it` 模式,因此从 Mocha 的迁移非常顺利。
然而,与使用 Mocha 相比,在开发者体验方面仍然存在一些小问题。
例如,在 Mocha 中运行一个单独的测试套件,使用 `it.only` 就足够了。而使用 Node.js 测试运行器,您必须
- 使用 `--test-only` 参数运行 CLI。
- 在包含要运行的 `it.only` 的 `describe` 上添加 `.only`。
- 如果存在多个 `describe` 实例,则所有这些实例都需要标记为 `.only`。
另一个例子是使用 `--test-name-patterns` 可以改进。此参数用于仅运行与特定名称模式匹配的测试。开发者体验不佳,因为 CLI 会在终端上输出大量关于不匹配的测试(未运行)的消息。这使得更难以理解哪些测试实际运行了。此外,仅运行匹配某些模式的测试,该命令非常慢。
Node.js 测试运行器仍然年轻,凭借其积极的开发,它具备了变得更好的所有条件。例如,在我们表达了我们的用例后,Node.js 项目目前正在评估使用主进程运行测试。
本着真正的开源协作精神,我们很高兴通过将测试切换到 Node.js 来改进 Astro,反过来也将改进 Node.js 本身!