第11章 第 11 章:Vitest 基准测试指南
Benchmarking Vitest 允许你通过 test context 中的 bench 工具(fixture)在测试旁边编写基准测试。内置的基准测试提供程序由 Tinybench 驱动,基准测试定义在常规的 test() 调用内部,从而让你能够使用 Vitest 测试运行器的全部功能:重试、生命周期钩子、过滤以及断言。
定义基准测试 使用 bench 工具来定义基准测试。调用 .run() 来执行它: ts import { test } from 'vitest'
test('parsing performance', async ({ bench }) => { await bench('parse', () => { JSON.parse('{"key":"value"}') }).run() }) bench() 函数注册一个基准测试但不立即执行。调用 .run() 会运行该基准测试并返回结果。测试完成后,Vitest 会打印单行版本的对比表格(包含每秒操作数 ops/sec、平均时间、百分位数等),因此单次基准测试的输出效果与 bench.compare() 相同。 注意 bench 工具仅在匹配 benchmark.include(默认:/*.{bench,benchmark}.?(c|m)[jt]s?(x))的文件中可用。在常规测试文件中使用 { bench } 会抛出错误。 某个文件是否参与基准测试运行,取决于文件名,而非测试是否使用了 bench 工具。将 parser.test.ts 重命名为 parser.bench.ts(或调整 benchmark.include)即可将其移入基准测试项目。
运行基准测试 基准测试文件通过 benchmark.include(默认:/*.{bench,benchmark}.?(c|m)[jt]s?(x))匹配,并在独立的隔离项目中运行,与常规测试分开。根据你是否想跳过、与测试一起运行或单独运行,有三种运行方式。
vitest(默认) 在未设置 benchmark.enabled 的情况下,vitest 命令仅运行常规测试。基准测试文件会被完全忽略。这是默认行为,也是日常开发的正确选择,因为基准测试速度慢且噪声大,不应在每次保存时运行。
vitest with benchmark.enabled 在配置文件中设置 benchmark.enabled: true,即可让基准测试与常规测试一起运行: vitest.config.ts ts import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { benchmark: { enabled: true, }, }, }) 在此配置下,vitest 先运行常规测试,然后在独立的隔离组中运行基准测试(确保基准测试执行不会与测试执行重叠,避免干扰结果)。这在 CI 环境中很有用,当你希望用一条命令同时验证正确性和性能时。
vitest bench bench 子命令仅运行基准测试,跳过常规测试: bash vitest bench 这会隐式为当前运行启用 benchmark.enabled,因此无需在配置文件中设置。与 vitest 命令一样,它接受文件名过滤器和 -t/--testNamePattern 来缩小运行范围: bash # 仅运行文件名匹配 "parser" 的基准测试 vitest bench parser
# 仅运行测试名称匹配 "JSON" 的基准测试 vitest bench -t JSON 若要使用其他基准测试引擎或执行策略,请参阅自定义基准测试提供程序指南。
对比基准测试 使用 bench.compare() 来对比多个基准测试: ts import { test } from 'vitest'
test('compare JSON libraries', async ({ bench }) => { const input = '{"key":"value","nested":{"a":1}}'
await bench.compare( bench('JSON.parse', () => { JSON.parse(input) }), bench('custom parser', () => { customParse(input) }), ) }) 在对比基准测试时,Vitest 采用交错迭代(interleaved iterations)来减少环境偏差(如 CPU 降频、GC 压力等),并在测试完成后打印对比表格: benchmark-table.ansi ✓ bench test/basic.bench.ts (1 test) 3446ms ✓ different libraries 3445ms name hz min max mean p75 p99 p995 p999 rme samples lib 1 40,678,348.31 0.0000 0.0002 0.0000 0.0000 0.0000 0.0000 0.0000 ±0.95% 53989 fastest lib 3 39,152,132.23 0.0000 0.0002 0.0000 0.0000 0.0000 0.0000 0.0000 ±0.93% 52080 lib 2 38,088,138.97 0.0000 0.0066 0.0000 0.0000 0.0000 0.0000 0.0000 ±1.58% 50618 slowest
Test Files 1 passed (1) Tests 1 passed (1)
选项 可以将选项作为 bench.compare() 的最后一个参数传递: ts test('compare with options', async ({ bench }) => { await bench.compare( bench('lib1', () => { lib1() }), bench('lib2', () => { lib2() }), { iterations: 100, time: 1000, }, ) }) 也可以像 test() 接受选项那样,将选项作为第二个参数传递给每个基准测试: ts test('benchmarks with setup', async ({ bench }) => { await bench.compare( bench('with-cache', () => { readFromCache() }), bench( 'without-cache', { beforeEach: () => clearCache() }, () => { readFromDisk() }, ), ) })
跨项目对比 当工作区定义了多个项目(例如不同的浏览器或运行时)时,在 bench 选项中传递 perProject: true,即可对比同一基准测试在所有项目中的表现。Vitest 仍会在当前项目中内联打印结果,并在测试运行结束时将所有项目的结果汇总到一张对比表中。 ts import { test } from 'vitest'
test('simple example', async ({ bench }) => { await bench('1 + 1', { perProject: true }, () => { 1 + 1 }).run() }) 相同的测试文件在每个项目(chromium, firefox, webkit 等)中运行,Vitest 会分组显示结果: benchmark-per-project.ansi Cross-Project Benchmark Comparison
test/parser.bench.ts > regular parsing > parsing name hz min max mean p75 p99 p995 p999 rme samples webkit (bench) 6,730,808.15 0.0000 1.0000 0.0001 0.0000 0.0000 0.0000 0.0000 ±6.20% 6731808 fastest chromium (bench) 3,841,599.95 0.0000 0.2000 0.0003 0.0000 0.0000 0.0000 0.1000 ±1.96% 3851571 firefox (bench) 3,546,066.28 0.0000 5.0000 0.0003 0.0000 0.0000 0.0000 0.0000 ±6.26% 3547062 slowest 也可以在 bench.compare() 中混合使用 perProject 基准测试和常规基准测试: ts test('compare implementations across browsers', async ({ bench }) => { await bench.compare( bench('JSON.parse', { perProject: true }, () => { JSON.parse('{"key":"value"}') }), bench('custom parser', () => { customParse('{"key":"value"}') }), ) }) 在这种情况下,custom parser 出现在每个项目的常规内联对比表中,而 JSON.parse 则额外被收集到测试结束时的跨项目对比表中。
断言性能 使用 toBeFasterThan() 和 toBeSlowerThan() 匹配器来断言基准测试之间的相对性能: ts import { expect, test } from 'vitest'
test('lib1 is faster than lib2', async ({ bench }) => { const result = await bench.compare( bench('lib1', () => { lib1() }), bench('lib2', () => { lib2() }), )
expect(result.get('lib1')).toBeFasterThan(result.get('lib2')) }) delta 选项指定断言通过所需的最小相对差异。这有助于避免由基准测试噪声引起的不稳定测试(flaky tests): ts // lib1 必须至少比 lib2 快 10% expect(result.get('lib1')).toBeFasterThan(result.get('lib2'), { delta: 0.1, })
// lib2 必须至少比 lib1 慢 20% expect(result.get('lib2')).toBeSlowerThan(result.get('lib1'), { delta: 0.2, }) 也可以使用标准匹配器断言绝对性能: ts test('parsing is fast enough', async ({ bench }) => { const result = await bench('parse', () => { parse(largeInput) }).run()
expect(result.throughput.mean).toBeGreaterThan(10_000) })
重试 由于基准测试可能存在噪声,使用 retry 选项自动重试失败的基准测试: ts test('performance comparison', { retry: 3 }, async ({ bench }) => { const result = await bench.compare( bench('lib1', () => { lib1() }), bench('lib2', () => { lib2() }), )
expect(result.get('lib1')).toBeFasterThan(result.get('lib2')) })
存储和重放结果 两种机制允许你将基准测试结果持久化到磁盘,并在后续运行中与其对比:writeResult 选项保存结果,而 bench.from() 读取结果。
writeResult 将 writeResult 作为每个基准测试的选项传递,可在每次基准测试运行时将结果写入 JSON 文件。路径相对于项目根目录解析: ts test('parse', async ({ bench }) => { await bench( 'parse', { writeResult: './benchmarks/parse.json' }, () => parse(largeInput), ).run() }) 基准测试总会运行。没有“如果缓存则跳过”的行为,也没有 CLI 标志,文件在每次成功运行后都会被覆盖。 如果函数抛出异常,文件不会写入。 请将这些文件与代码一起提交,以便审查者和 CI 共享相同的参考点。 注意 如果你提交这些文件,请记住基准测试结果在不同环境(开发机器、CI 运行器、不同操作系统)之间差异很大。指定一个环境(通常是 CI)来生成该文件,并避免在本地重新生成。
bench.from() bench.from(name, source) 是一种不执行函数的注册方式。它读取之前存储的结果,并将其输入到 bench.compare()(或在调用 .run() 时直接返回)。 source 可以是路径(相对于项目根目录),也可以是返回结果数据(包括 Promise)的函数: ts test('compare against the stored baseline', async ({ bench }) => { const result = await bench.compare( bench( 'current', { writeResult: './benchmarks/parse.json' }, () => parse(largeInput), ), bench.from('previous', './benchmarks/parse.json'), bench.from('remote', () => fetch('https://path/to/external/file.json').then(r => r.json())), )
expect(result.get('current')).toBeFasterThan(result.get('previous')) }) 你可以保留旧版本的历史工件,并将其与当前实现进行对比。由于 bench.from() 不会调用生成文件的原始函数,一旦工件被提交,原始的基准测试代码就可以删除了: ts test('compare parser versions', async ({ bench }) => { const input = '{"key":"value"}'
await bench.compare( bench.from('v1', './benchmarks/parse.v1.json'), bench.from('v2', './benchmarks/parse.v2.json'), bench( 'current', { writeResult: './benchmarks/parse.current.json' }, () => customParser(input), ), ) }) 要生成新的历史工件,请将新的 bench() 指向该版本的实现,设置 writeResult 为带版本的路径(./benchmarks/parse.v3.json),运行一次,然后将调用替换为 bench.from('v3', './benchmarks/parse.v3.json')。 要按需重新生成基线,可以将写入操作置于环境变量之后,使相同的测试要么刷新工件,要么与其对比: ts test('compare parser versions', async ({ bench }) => { if (import.meta.env.VITE_WRITE_BENCH) { const baseline = bench('baseline', { writeResult: './my-bench.json' }, () => fn()) await baseline.run() } else { const baseline = bench.from('baseline', './my-bench.json') await bench.compare(bench('current', () => fn()), baseline) } }) 运行 VITE_WRITE_BENCH=1 vitest bench 以刷新存储的结果,运行 vitest bench 以将当前实现与其进行对比。
按项目的工件 在多项目工作区(不同浏览器、不同运行时)中,通过在路径中包含 ${projectName} 来跨项目共享一个基准测试文件。占位符会在写入时替换为当前项目名称: ts test('cross-project baseline', async ({ bench }) => { await bench( 'parse', { perProject: true, writeResult: './benchmarks/parse.${projectName}.json' }, () => parse(largeInput), ).run() }) 在 bench.from() 中使用相同的模板,以便每个项目读取自己的工件。
稳定性 基准测试天生不稳定:CPU 负载、热降频、GC 压力和后台进程都会影响结果。Vitest 采取了若干措施以最小化这种噪声: 独立项目:基准测试文件根据 benchmark.include 模式分组到独立项目中。bench 工具仅在该模式匹配的文件中暴露。在常规测试文件中使用它会抛出错误。 无并发:基准测试文件内的测试始终顺序运行。基准测试文件本身也是一次一个运行,绝不并行。这防止基准测试相互干扰。 为了进一步提高稳定性: 使用 retry 选项自动重新运行不稳定的基准测试断言。 在 toBeFasterThan / toBeSlowerThan 中使用 delta 选项以允许可接受的方差。 避免在 CPU 密集型进程旁边运行基准测试。 关闭争夺 CPU 时间的浏览器、IDE 和其他应用程序。
死代码消除 JavaScript 引擎可以优化掉没有可观察副作用的代码。如果你的基准测试函数没有使用其结果,引擎可能会完全跳过计算,产生误导性的高速度数字: ts test('parsing', async ({ bench }) => { // 错误:引擎可能会消除这部分工作 await bench('parse', () => { JSON.parse(input) }).run()
// 正确:结果被消费 await bench('parse', () => { const result = JSON.parse(input) doSomething(result) }).run() }) 这适用于所有引擎(V8, JavaScriptCore, SpiderMonkey),但在 V8 的 TurboFan 和 JavaScriptCore 的 FTL 编译层中尤为激进。
模块运行器开销 默认情况下,Vitest 在 Node.js 中使用 Vite 的模块运行器(由 experimental.viteModuleRunner 配置)运行测试。这会将所有模块导出转换为 getters,因此每次访问导入的绑定都会经过类似 __vite_ssr_module__.value 的过程。在常规测试中,这种开销可以忽略不计,但在函数被调用数百万次的基准测试中,getter 调用本身可能主导测量结果。 如果检测到过多的 getter 调用,Vitest 会打印警告(可通过 benchmark.suppressExportGetterWarnings 关闭),但你在基准测试导入函数时应意识到这一点: ts import { parse } from './parser.js'
const _parse = parse
test('parsing', async ({ bench }) => { // 错误:每次调用 `parse` 都经过 getter await bench('parse', () => { parse(input) }).run()
// 正确:将引用存储在本地以绕过 getter await bench('parse', () => { _parse(input) }).run() }) 如果你是库作者,相同的开销也适用于你正在基准测试的库内部:其源码中的每个跨模块调用都经过相同的 getter 包装器。如果你正在基准测试自己的库,有两种方法可以消除这种开销: 基准测试预构建的工件。通过包名(解析到其构建输出)导入库,而不是深入其源码。构建后的文件已将内部导入折叠为直接引用,因此 Vite 的模块运行器将其视为单个模块,没有内部 getters: ts // 错误:库内部的每个调用都经过 getter import { parse } from '../src/index.ts'
// 正确:发布的入口没有内部 getters import { parse } from 'my-library' 如果你将你的库与其他包进行对比,请为所有实现基准测试相同类型的工件。对于工作区包,确保包名解析到构建输出而非源码,例如通过在 Vite 中外部化该包或从 dist 导入。 为基准测试禁用模块运行器。如果基准测试不需要 Vite 转换、mocks 或 Vitest 模块拦截,请为基准测试项目禁用 experimental.viteModuleRunner,以便 Node 直接运行原生 ESM。 这只影响 Node.js 模式。浏览器模式使用原生 ESM 导入,没有这种开销。
引擎特定注意事项 V8(Node.js, Chrome) JIT 分层:V8 通过多个优化层级(Sparkplug → Maglev → TurboFan)编译函数。函数在预热期间和稳态期间的运行速度可能不同。Tinybench 自动处理预热,但非常短的基准测试运行可能达不到最高的优化层级。 去优化:如果 V8 在基准测试中途遇到意外的类型或形状,它会“退出”优化代码。请保持基准测试函数中的类型一致: ts test('process items', async ({ bench }) => { // 错误:混合形状导致去优化 await bench('process', () => { for (const item of items) { // 某些 item 有 { name: string },其他有 { name: string, id: number } process(item) } }).run()
// 正确:对象形状一致 await bench('process', () => { for (const item of items) { // 所有 item 都有相同的形状 { name: string, id: number } process(item) } }).run() }) 垃圾回收:基准测试循环中的大型分配会增加 GC 噪声。如果你在测量计算,请在设置钩子中预分配数据,而不是在基准测试函数内部: ts test('sorting', async ({ bench }) => { const original = Array.from({ length: 10000 }, () => Math.random()) let data: number[]
// 错误:每次迭代分配新数组,GC 增加噪声 await bench('sort', () => { const data = Array.from({ length: 10000 }, () => Math.random()) data.sort() }).run()
// 正确:预分配,在 beforeEach 中复制 await bench( 'sort', () => { data.sort() }, { beforeEach() { data = [...original] }, }, ).run() })
JavaScriptCore(Bun, Safari) 不同的优化阈值:JSC 使用自己的 JIT 层级(LLInt → Baseline → DFG → FTL),具有不同的内联和优化启发式算法。在 V8 上快速的基准测试在 JSC 上表现可能截然不同。 异步基准测试:Bun 的事件循环实现与 Node.js 不同。如果你的基准测试涉及异步操作或计时器,结果可能无法在不同运行时之间直接比较。
浏览器 计时器分辨率:浏览器可能会降低 performance.now() 的精度(例如降至 100μs 甚至 1ms)作为一种安全机制。这使得难以准确测量极快的操作,因此请增加迭代次数以补偿: ts test('fast operations', async ({ bench }) => { await bench.compare( bench('fast-op', () => { fastOp() }), bench('other-op', () => { otherOp() }), { // 更多迭代有助于克服低计时器分辨率 iterations: 1000, }, ) }) 跨浏览器差异:V8(Chrome)、SpiderMonkey(Firefox)和 JSC(Safari)对不同的模式进行不同的优化。在 Chrome 中显示某个库胜出的基准测试,在 Firefox 中可能显示相反的结果。