Miro 前端团队将 600 万行代码仓库的 7 万个测试从 Jest 迁至 Vitest,CI 时间从 20 分钟缩短,换来并行 runner 数量减少与维护效率提升。
你们猜对了。我们把 70,000 个测试(读作:七万)从 Jest 迁移到了 Vitest。合理的问题来了:怎么做到的?这就是故事的开始。我在 Miro 的前端开发者体验团队担任软件工程师。我们的职责覆盖平台的各个部分。我们主要的关注点之一是前端基础设施和工具链——所有能让 Miro 产品团队高效工作的东西。2026 年对我们来说确实是关键的一年:我们终于干掉了 Angular.js(我知道,这很厉害),从 Prettier 切换到 Oxfmt,抛弃了 ESLint 改用 Oxlint,用 TypeScript 7 运行 CI 类型检查,还有很多其他改进。但你看,你来这是为了读单元测试的内容!这篇文章里,我会讲讲我们是如何把 Miro 主前端应用的单元测试框架从 Jest 迁移到 Vitest 的。
Miro 的主应用在不断演进,现在已经有约 600 万行代码(LoC)。在这个规模下,常规的方法和工具都不管用了。我怎么知道?以下是关于我们 CI 状态的具体事实,特别是单元测试任务:
前景并不乐观,这对我们的应用发布有很大影响。但我们能做什么呢?
翻阅 Slack 消息,我发现有几个帖子讨论我们是否要迁移出 Jest。早在 2023 年 11 月,我的一位同事写道:
虽然我也花了一些时间探索替代方案(vitest),但我发现实际上,vitest 特定的额外配置和复制我们当前的构建可能风险太大,而且不确定现在依赖 @swc/jest 能获得多少速度收益
那时,问题的规模还算可控——我们刚开始在 CI 上并行运行单元测试,用额外的 runner 计算能力来弥补等待时间。效果还不错。
2024 年 7 月,我的另一位同事再次对单元测试工具发表了评论:
我期待从更好的测试选择中获得更高的回报(例如,不是每次都运行 35k 个单元测试)。切换到 vitest(例如)的成本很高,而且改善程度不明确
说得有道理!6 个 runner 并行处理 35,000 个测试,每个大约只需要 7 分钟。以这样的任务耗时,我们有更大的问题需要解决,比如端到端测试。
时间快进到 2025 年 3 月。我们开始遇到越来越多 Jest 测试栈的问题。我只举一个促使我探索 Jest 替代方案的例子:显然,我们的工程师报告说,使用 VSCode Jest 扩展时,安装了多个版本的 fb-watchman(Jest 内置的文件系统监视库),这导致 CPU 饱和,进而无法使用他们的笔记本电脑。我们找到了用 watchman: false 解决的方法。那时,前端开发者体验团队的高级工程师 Ahmed 受够了:
我想说也许是时候切换到 vitest 了,但那是一项巨大的工作量……
我们进行了相关讨论,但我没有立即承担迁移领导的能力。我们也看到 AI 模型越来越受关注,以及它们如何快速演进以应对越来越复杂的问题。所有迹象都表明应该再等等。就这样我们来到了 2026 年。
现在你可能会问:"Sergey,鉴于你刚才提到的所有事实,为什么你决定重新探索迁移?"别误会,我们不是为了酷而解决这种规模的问题。我们需要理解我们想要达成什么。我先从事实开始:
Miro 主应用是一个包含 web 和 native 部分的 monorepo。它由 yarn 和 NX 管理。在 2026 年初,我们的代码库中有 800+ 个包。最大的代码份额位于 web core,占代码库的大约 20%。我们还有约 70,000 个单元测试(两年翻了一番,想象一下),而且单元测试任务平均需要长达 20 分钟才能完成,这使它们成为我们 pipeline 中最慢的 CI 任务,甚至比构建应用和在最新版本上运行 e2e 测试还慢!
现在,我们已经能看到我们正在对抗的几个痛点。我可以为你整理出完整的列表:
require(),可以获取 mock 的实现我可以继续说下去,但我觉得这已经足够令人印象深刻了。现在,你不能随便拿 70k 个测试用某个随机测试框架重写(想象一下如果可以的话!)。你还必须教育数百名产品工程师,变化的规模将是巨大的。还有所有的自定义插件、matcher 和支持依赖呢?想想 jest-axe、jest-when、React Testing Library 等。所有这些问题的答案——我们在寻找一个 Jest 兼容的 API。足够兼容,这样我们就不会把 monorepo 变成一团糟。
现在,市场调查给了我们两个替代方案:Rstest 和 Vitest(截至 2026 年 3 月的版本):
Rstest:
Vitest:
你可以猜到 Vitest 赢了,但为什么?我做了功课:我创建了两个分支,分别向每个框架迁移了大约 10k 个测试。然后我尝试在不同模式下执行测试。以下是结果(数值是每秒测试数,越大越好):

你已经注意到 Rstest 无法在 CI 上运行,主要是因为 worker 池的内存管理不佳。请记住那是 pre-1.0 版本,runner 正在积极开发中,所以期望很低(我鼓励你尝试最新版本并探索当前状态)。与此同时,迁移这个小型测试子集显示了 Vitest 内存消耗的巨大差异——与 Jest 中相同的测试组相比,runner 少用了超过 10GB 内存 🚀。
就在这里坦白说,速度和 RAM 消耗确实是主要因素之一。虽然研究没有显示任何有意义的速度提升,我们决定执行迁移,因为我们看到了优化 Vitest 配置并尝试使其更快的机
会。它也涵盖了我们要解决的其他痛点。是的,我们有了领跑者,现在我们来看看计划!
如我之前所说,Miro web 应用是一个由 NX 和 Yarn 编排的 monorepo。它结合了 web core monolith,占所有代码的约 20%。其他部分是:800+ 个包和 native 应用的部分。由于 native 部分有自己独立的 CI 和发布 pipeline,我们决定将其排除在范围之外。另外,应用每天接受 60+ 次合并到主分支。虽然我很想"只是"让 AI 智能体一次性迁移所有内容,但我们无法执行。我决定采用渐进式推广:
在 CI 上并行运行独立的 runners,分别执行 Jest 和 Vitest。随着迁移的推进,根据已迁移测试的占比重新分配 runners。
从 package 层面开始。这样我们可以交付有价值的部分,也容易发现哪些测试应该保留在 Jest,哪些应该迁移到 Vitest。
使用 AI 智能体来迁移测试。我们在仓库中为智能体创建一个 skill,使智能体拥有所有必要的上下文。每个迁移阶段结束后,我们会更新 skill 文件中未提及的模式。
最后这一点是最强大的。现在,智能体可以学习并记住新的模式。这使得迁移越来越快,每次迭代产生的幻觉也越来越少。这也使得迁移更加一致——智能体会应用它们之前见过的相同模式。不仅如此,迁移完成后,你将得到一份详尽的代码示例列表,这些都是智能体在测试中发现的——这是一个极好的洞察来源。它可以轻松地为后续跟进、良好实践和知识共享打开空间。
我首先为 Vitest 搭建基础设施。基础工作包括创建可在应用中复用的 Vitest 配置的第一个版本。然后我们添加了兼容性桥接。比如共享的 mocks 和生成假数据的 helpers。在迁移期间,我们不希望重复这些文件。因此,我们在 Vitest 配置中添加了 globalThis.jest = vi。在 Jest 中添加 globalThis.vi = jest。是的,API 并非 100% 兼容,但在我们的场景中这就足够了。现在,整个迁移思路都是围绕按 package 逐步切换展开的。每个 package 通过 CLI 命令运行脚本,可以理解为执行 miro-scripts test。我添加了 runner 的 opt-in 参数,这样我们就可以区分要启动哪个测试 runner。在 package.json 中是这样的:miro-scripts test --runner=vitest。这不仅仅是本地的事情。这个 flag 让我们看到了如何在 CI runner 上运行这些测试。当我们按工具划分 runner 后,我们将所有 Vitest 项目合并为一个 runner 池,其余的留给剩余的 Jest 项目。最后,我们调整了可观测性指标以跟踪进度和测试分布。我们为测试相关的 Prometheus 指标添加了额外的 runner 标签,这使得可视化迁移过程成为可能。
现在我们已经准备好开始实际的迁移了。所有的护栏都已就位,CI 接管了所有测试,所以我们不会丢失质量。唯一缺失的是 Vitest 的测试覆盖率,但我们商定在过渡期后再处理。我从小 package 入手。这样可以保持 pull request 的规模可控。通过这种方式我可以:
AI skill 的第一个版本准备好后,我确保专注于社区工作,在内部进行演示,并解释每个团队如何自己动手进行迁移。Skill 就在仓库里,只需要求你的智能体迁移你所属目录下的 package 即可!✨
迁移工作开门红。前 90 个 package 进行得相当顺利。在此期间,我们构建了必要的 Vitest 插件来覆盖 Jest 配置的部分,并添加了相当数量的全局 polyfills。我有意没有要求 AI 智能体镜像 Jest 配置——没有人碰它已经好几年了,它吸收了太多历史遗留,我不想把它移植到 Vitest,而是从零开始。我还尝试按所有权进行迁移,即在单个 pull request 中只转换单个拥有团队的测试。不幸的是,这并没有如预期那样工作。我们有太多的跨 package helpers 和 fixture 生成器,所以从来不是单一团队的事情,而是这里那里都有几个 owner。但对我们来说这仍然是可以接受的。在那个时候,我非常有信心,决定一次性处理我们仓库中最大的 package 之一(仅次于 web 单体)。约 600 个测试文件同时进行,这是对迁移流程的真正压力测试。BOOM,我们把它合并到了主分支!这是一个巨大的里程碑,证明我们可以继续下去。另一方面,我们继续与前端社区合作,对这项工作非常透明。因此,我们开始收到关于整体进展和迁移进行得多么顺利的积极反馈。
虽然机械化的迁移进展顺利,但我们的责任从未改变——保持 CI 流程稳定。我们不仅需要迁移代码,还需要跟踪指标。不幸的是,指标开始显示一场即将到来的灾难。CI runner 开始消耗越来越多的内存。我很困惑!我最初的研究表明 Vitest 消耗的内存要少得多,但我们已经完成了大约 50% 的迁移,而且情况只会越来越糟?!我们不得不暂停一切直到找出原因。想象一下我当时的精神状态:我们正处于过渡期。我无法回滚一切,但也无法继续。我现在该怎么办?我失败了吗?!我被这个问题困扰了好几天,直到我意识到:仓库中的每个 package 都被定义为 Vitest 项目(为简单起见,我将交替使用 package 和项目这两个术语)。原来,Vitest 项目不共享转换缓存!每个项目都在底层用唯一标识符构建缓存。如果 package 之间高度互连,Vitest 会为每个 package 启动一个 Vite 服务器,一遍又一遍地转换相同的模块图。因此,10 个链接的 package 将转换相同模块 10 次。现在把它扩展到 800 个 package,你就会明白了。在我最初的研究中,我只使用了单个 Vitest 项目内的测试子集,因此,我实际上无法预见这个问题。解决方案是运行更少的项目,不是忽略其中一些,而是将项目合并到单个实例中。幸运的是,我们应用中的大多数 package 都有默认的共享 Vitest 配置,没有覆盖项。因此,很容易编写一个项目发现脚本,将所有具有默认配置的 package 合并到单个项目中。通过这样做,我们能够只运行 60 个 Vitest 项目,而这些项目实际包含了 400 个 package(59 个自定义项目 + 1 个包含其余文件的项目)。我会给你一个数字方面的剧透:
好奇的读者可能会问:为什么环境时间和测试时间增加了?想象你并行运行 10 个 Vitest workers。而只有一个主进程运行 Vite 来转换导入树。在更改之前,workers 只是在主进程一遍又一遍地转换相同模块时挂起等待。一旦我们合并了项目,转换时间显著下降。现在 workers 不再等待,它们运行更多测试,这导致 CPU 饱和和系统压力总体增加。这有效地导致 worker 性能变慢。但由于实际测试运行只占整个测试执行过程的一小部分,这种性能下降可以忽略不计。也就是说,将所有标准项目合并到单个实例中对性能产生了卓越的影响,并证实了我的假设。最重要的是,我们现在可以继续迁移了!
接下来的 60 个 pull request 仅仅是机械性的工作。AI 智能体已经建立了全面的 skill 上下文。我的工作是检查那些看起来不太好和非平凡的变更。然后还要听取审查者的反馈,因为我不可能在这个世界上了解 Miro 应用中的每一个单元测试。一个漂亮的点睛之笔是,随着 Jest → Vitest 迁移,我们开始注意到一些测试 fixture 意外泄露到了我们的生产包中(还记得第 8 点吗?)。合乎逻辑的问题是:怎么会?我们是如何检测到这一点的,测试数据又是如何泄露到生产包中的?
第一部分对我们来说很容易——我们使用 Rspack 构建 Web 应用,配置中没有任何覆盖或补丁来支持 Node.js 模块。事实上,Vitest 底层使用的是 NodeJS API(毕竟它是一个 Node 库)。因此,一旦依赖图中导入了 Vitest 的任何部分,Rspack 就会无法构建应用。
这种情况通常是怎么发生的?
Barrel 文件。仓库中的每个包都通过 barrel 文件 index.ts 导出其 API。虽然在小项目中运行良好,但随着应用规模增大,barrel 文件只会带来痛苦。Barrel 文件,尤其是星号导出(export * from './foo.ts')很难进行 tree-shaking,而且你永远不知道到底导出了什么。一些测试的 mock 形状也被意外地间接导出了。
有些团队实际上在生产代码中使用了测试辅助工具来创建虚拟用户和账户。这些是边缘情况,但确实发生过。
Vitest 迁移清楚地暴露了这些问题,促使我们改进了代码库。这让我们在完整生产构建中减少了大约 4MB。
那时我们已经迁移了所有包,AI 智能体现在有了明确的迁移手册。现在是时候面对最终Boss了:Web 核心单体应用。1,800 个测试文件,16,500 个测试。我意识到在工作时间运行迁移风险太大——工程师每 10 分钟就合并一次 pull request。我会花更多时间解决冲突。我决定在周末处理(别担心,我补休了两天!)周六,我让 AI 智能体运行迁移。结果:1,985 个文件变更,+22,776 / -19,998 行 diff。这个 pull request 标记了每个团队进行 review,但我的计划是使用管理员覆盖审批直接合并。它并不完美,但已经足够好了。剩下的我们可以在工作时间用更小的 PR 来修复。我花了一整天查看变更日志!我点击了 Bypass rules and merge 按钮,然后等待 main 分支 CI 完成。我终于可以去睡觉了。我们做到了。
我不想给人留下过渡完美顺利的印象。完成机械性工作后,我们不得不清理配置、还原混合设置,并开始移除 Jest 依赖。但这没关系。更大的问题(讽刺的是)是 Vitest 是一个比 Jest 可靠得多的工具。它对模块导入有自己的超时机制,能捕获未处理的错误,会对重复的 vi.mock 块进行竞态检测。所有这些实际上使一小部分测试变得更加不稳定。我们花了一些时间来稳定测试,探索根本原因,并与所有者合作看看如何解决。少部分测试(大约 27 个测试用例)为了 CI 稳定性被完全跳过了。
但我脑海中仍有一个问题——我们通过合并项目加快了运行速度并节省了内存。我们能把所有东西合并到一个 Vitest 项目中吗?过渡到 Vitest 的优势之一是我们能够丢弃大量遗留配置。这使得所有自定义 Vitest 配置看起来像这样:
import { defineProject, mergeConfig } from 'vitest/config';
import defaultCfg from '@shared/config/vitest/shared.config.js';
export default mergeConfig(
defaultCfg,
defineProject({
test: {
setupFiles: ['./setupVitest.ts']
}
})
);
只有 setup 脚本是我们创建自定义配置的原因。这为什么重要?如果 setup 文件是唯一的区别,也许我可以检测正在运行的测试文件并调用相对于该文件的 setup 脚本!没有相关文档。我得到的唯一提示是这个页面,它解释了 expect API 的接口。显然,expect.getState: () => MatcherState,然后 MatcherState 有 testPath 字段——绝对测试路径,这正是我们需要的。现在我快速启动了共享 setup 脚本,它接收测试路径,推导出测试所在的包,然后动态执行附加到该特定测试文件的 setup 脚本。以下是思路:
import { existsSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import { expect } from 'vitest';
const repoRootDir = '%repo_root_path%';
const absoluteTestPath = expect.getState().testPath;
const relativeTestPath = absoluteTestPath ? relative(REPO_ROOT_DIR, absoluteTestPath) : '';
// you can manipulate the path to find the right location here
const vitestProjectRootDir = fetchProjectDirForTestFile(relativeTestPath);
const setupFile = vitestProjectRootDir ? resolve(repoRootDir, vitestProjectRootDir, 'setupVitest.ts') : '';
if (setupFile && existsSync(setupFile)) {
await import(setupFile);
}
你可以使这个脚本与项目无关,为每个测试文件执行它。通过这样做,我们能够将应用中所有 800+ 个 Vitest 项目合并到一次测试运行中。没有转换开销,没有缓存重复。让你了解一下它有多大的影响力,我会给你一个样本:
我们终于找到了速度的来源 ✨ 这也结束了我们在主前端应用中落地 Vitest 的历程。
注意我们增加了近 9,000 行净代码。实际净增量要低得多,因为我们从全局注入的 API(想想 jest、it、describe 等)改为本地导入的对象(import {describe} from 'vitest')。加上代码格式化带来了一些 diff。现在说说技术细节:
测试运行速度比 Jest 快 50%(从 13 test/sec 到 19 test/sec)
Vitest 在 CI runner 上消耗的内存减少 30%
Vitest mock 是类型安全的,即它们从 mock 的对象继承签名,使工程师能够编写更好的单元测试
ESM 优先。Mock 会自动提升,这再次为工程师提供了更好的体验
生产包节省了约 4MB,测试 fixture 从生产 barrel 文件中移出
真正的测试隔离——每个测试文件都在隔离的 worker 上运行