Supabase 仓库中的 Vitest 并发与并行测试实践:从文件级并行到 CI 分片
本篇基于 Supabase 开源仓库中随仓库维护的 Vitest 技能参考文档 features-concurrency.md 展开,系统讲解 Vitest 的并发(concurrency)与并行(parallelism)测试体系:文件如何在多个 worker 间并行调度、单个测试文件内部的测试如何并发执行、如何在 CI 中用分片(sharding)把测试摊到多台机器上。读完本文,你可以直接在类似本仓库的 pnpm monorepo 中落地并发配置,并理解 apps/studio、packages/ai-commands 等模块中的真实用法。
1. 两个层级的"并行":先分清概念
Vitest 的并行执行其实分两个互不相同的层级,混淆它们是最常见的配置误区:
- 文件级并行(File Parallelism):多个测试文件被分配到多个 worker 进程/线程中同时执行,由
fileParallelism、maxWorkers/minWorkers、pool等配置控制; - 测试级并发(Concurrent Tests):单个文件内部的多个
test并发跑(而不是顺序跑),由test.concurrent/describe.concurrent等 API 控制。
文件级并行是默认开启的,几乎所有仓库(包括 Supabase 的 studio、docs、www 等 app)不写任何配置就自动受益;测试级并发则需要显式声明,因为 Vitest 默认在同一文件内顺序执行测试。
版本前提:本仓库通过 pnpm-workspace.yaml 的 catalog 机制统一锁定
vitest: ^4.1.4(同版本还有@vitest/coverage-v8与@vitest/ui),各子包(如 apps/studio/package.json、apps/www/package.json)均以"vitest": "catalog:"引用该版本。本文引用的技能文档基于 Vitest 3.x API 生成(见 SKILL.md 的 metadata 说明),上述并发相关 API(.concurrent、--shard、sequence)在 3.x/4.x 间保持稳定,但具体默认值请以你安装的版本为准。
2. 文件级并行与 worker 配置
2.1 核心配置项
默认情况下 Vitest 就把测试文件并行分发给 worker,显式写法如下:
defineConfig({
test: {
// Run files in parallel (default: true)
fileParallelism: true,
// Number of worker threads
maxWorkers: 4,
minWorkers: 1,
// Pool type: 'threads', 'forks', 'vmThreads'
pool: 'threads',
},
})
参数说明:
fileParallelism:布尔值,默认true。关掉后所有文件在一个 worker 内顺序执行,一般只在排查与并行强相关的诡异 bug 时才需要;maxWorkers/minWorkers:worker 数量的上限与下限。worker 过多会放大内存占用(每个 worker 都是独立的模块实例),在 CI 容器这种资源受限环境里通常要显式压低;pool:worker 的承载技术,可选'threads'(worker_threads,默认)、'forks'(子进程)、'vmThreads'(VM 隔离线程),第 8 节会逐一分析。
2.2 本仓库的真实配置对照
Supabase 各子包并没有在配置里显式写 fileParallelism——这正是"默认即并行"的体现。看三个有代表性的真实配置:
apps/studio/vitest.config.ts(最大的前端应用,jsdom 环境 + CI 下自动重试):
const IS_CI = !!process.env.CI
export default defineConfig({
plugins: [react(), tsconfigPaths({ projects: ['.'] })],
resolve: {
alias: { '@ui': resolve(__dirname, './../../packages/ui/src') },
},
test: {
globals: true,
environment: 'jsdom',
// Retry flaky tests in CI only; failures locally should surface immediately.
retry: IS_CI ? 2 : 0,
setupFiles: [
resolve(dirname, './tests/setup/polyfills.ts'),
resolve(dirname, './tests/vitestSetup.ts'),
resolve(dirname, './tests/setup/radix.js'),
],
// Don't look for tests in the nextjs output directory
exclude: [...configDefaults.exclude, `.next/*`, /* ... */],
reporters: [['default']],
coverage: {
reporter: ['text', 'text-summary', 'lcov'],
include: ['lib/**/*.ts'],
},
},
})
这里有个值得注意的并发配套策略:并行执行放大了 flaky 测试的暴露面(同一文件在不同 worker、不同时序下跑),所以 studio 配置用 retry: IS_CI ? 2 : 0 做了区分——CI 上重试 2 次以吸收时序抖动,本地保持 0 重试让失败立刻暴露。这是并行化测试中"稳定性与真实性"权衡的典型做法。
apps/docs/vitest.config.ts 则展示了另一个并行相关的要点——globalSetup:
test: {
exclude: ['examples/**/*', '**/node_modules/**'],
setupFiles: ['vitest.setup.ts'],
globalSetup: ['vitest.globalSetup.ts'],
}
从源码结构看,globalSetup 在整个测试进程只跑一次(共享外部资源,如启动 mock server),而 setupFiles 在每个测试文件/隔离环境里各自执行。当你为并发测试引入共享资源(数据库、端口)时,这个"一次 vs 每次"的语义边界决定了你的资源该放在哪一层初始化。
packages/ai-commands/vitest.config.ts 给 AI 调用类测试放宽了超时:
test: {
environment: 'node',
testTimeout: 30000,
setupFiles: ['./vitest.setup.ts'],
}
testTimeout: 30000 并非并发配置,但与并发强相关:当多个测试并发发起真实 LLM 请求时(见第 4 节实例),单个请求排队 + 网络延迟很容易超过默认 5 秒超时,放宽超时是并发化此类测试的必要配套。
3. 文件内并发:test.concurrent 与 describe.concurrent
3.1 单个测试并发
// Individual concurrent tests
test.concurrent('test 1', async ({ expect }) => {
expect(await fetch1()).toBe('result')
})
test.concurrent('test 2', async ({ expect }) => {
expect(await fetch2()).toBe('result')
})
// All tests in suite concurrent
describe.concurrent('parallel suite', () => {
test('test 1', async ({ expect }) => {})
test('test 2', async ({ expect }) => {})
})
test.concurrent 让这一条测试与同文件内其他测试并发执行;describe.concurrent 则把整个 suite 下的测试全部标记为并发。
关键约束(参考文档反复强调):并发测试中必须使用测试上下文里的 { expect },而不是从 vitest 顶层 import 的 expect。 原因是每个并发测试拥有独立的执行上下文(fixture、mock 状态),上下文注入的 expect 会绑定到当前测试,避免断言错误地落到别的并发测试上。
3.2 本仓库的真实用例
packages/ai-commands/src/sql/functions.test.ts 正是参考文档所描述模式的落地实现——两个并发发起 OpenAI 请求的 SQL 调试测试:
import { describe, expect, test } from 'vitest'
describe('debug', () => {
test.concurrent('fix order of operations', async ({ expect }) => {
const { sql } = await debugSql(
openai,
'relation "departments" does not exist',
codeBlock`...`
)
expect(formatSql(sql)).toMatchSnapshot()
})
test.concurrent('fix typos', async ({ expect }) => {
const { sql, solution } = await debugSql(
openai,
'syntax error at or near "fromm"',
codeBlock`
select * fromm employees;
`
)
expect(solution).toBeDefined()
expect(formatSql(sql)).toMatchSnapshot()
})
})
注意三个细节与文档完全对应:一是 describe 保持普通(顺序调度),只把两个 I/O 密集的 test 标记为 .concurrent,因为二者相互独立、无共享状态,天然适合并发;二是严格使用上下文参数 async ({ expect });三是配套的 testTimeout: 30000(见 2.2 节)保证了并发等待时不会误超时。这类"外部 I/O 密集 + 相互独立"的场景,正是文件内并发收益最大的地方。
4. 在并发上下文中强制顺序:sequential 修饰符
当 suite 整体并发的同时,个别测试又依赖顺序或共享状态时,用 test.sequential / describe.sequential 把它们"钉"回串行:
describe.concurrent('mostly parallel', () => {
test('parallel 1', async () => {})
test('parallel 2', async () => {})
test.sequential('must run alone 1', async () => {})
test.sequential('must run alone 2', async () => {})
})
// Or entire suite
describe.sequential('sequential suite', () => {
test('first', () => {})
test('second', () => {})
})
语义上可以理解为:concurrent 与 sequential 是同一调度维度上的两个极性,默认极性是顺序执行,.concurrent 把默认极性翻转,.sequential 则可以在翻转后的环境中把个别测试拉回默认极性,反之亦然。实践上最常见的组合就是参考文档所示的"大并发 + 小串行"——大部分测试独立可并行,少数涉及共享状态(如数据库事务、全局单例)的测试显式串行化。
5. 并发度上限与隔离
5.1 maxConcurrency
defineConfig({
test: {
maxConcurrency: 5, // Max concurrent tests per file
},
})
maxConcurrency 限制单个文件内同时运行的测试数,默认是 5。如果你的并发测试都打真实后端(如同 3.2 节打 OpenAI 的测试),这个值实际上就是你对该后端的并发请求数上限,调它等于调对下游服务的压力。
5.2 文件隔离(Isolation)
Vitest 默认让每个测试文件跑在隔离的环境中(模块图、全局状态、mock 注册彼此独立)。这是并行安全的根基,也是开销来源(每个文件都要重建环境):
defineConfig({
test: {
// Disable isolation for faster runs (less safe)
isolate: false,
},
})
isolate: false 可以让同一 worker 内连续运行的文件复用环境,显著提速,但代价是前一个文件泄漏的全局状态可能被后一个文件看到——一旦有测试污染了 globalThis 或第三方库单例,故障会呈现出与文件执行顺序强相关的"灵异"行为。参考文档把这一点直接标注为 "less safe",本仓库所有子包也均未关闭隔离,保持了默认安全配置。从源码结构看,只有当你确认测试彼此无状态耦合、且已用第 7 节的 shuffle 验证过顺序无关性时,关闭隔离才是安全的提速手段。
6. 分片(Sharding):把测试摊到多台机器
6.1 基础用法
--shard=i/n 让 Vitest 按稳定算法把文件集合切 n 份,只跑第 i 份:
# Machine 1
vitest run --shard=1/3
# Machine 2
vitest run --shard=2/3
# Machine 3
vitest run --shard=3/3
三台机器各自只承担 1/3 的文件,墙钟时间约降为 1/3,且每个分片的文件分配是可复现的。
6.2 CI 分片:GitHub Actions 矩阵 + 报告合并
参考文档给出的完整 CI 方案是"分片执行 + blob 报告 + 汇总合并"三步:
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3]
steps:
- run: vitest run --shard=${{ matrix.shard }}/3 --reporter=blob
merge:
needs: test
steps:
- run: vitest --merge-reports --reporter=junit
配合 blob reporter 的命令行形态:
# Each shard outputs blob
vitest run --shard=1/3 --reporter=blob --coverage
vitest run --shard=2/3 --reporter=blob --coverage
# Merge all blobs
vitest --merge-reports --reporter=json --coverage
工作流要点:每个分片用 --reporter=blob 把结果(含覆盖率)序列化落盘;最后一个 merge job 依赖全部 test job 完成后,用 --merge-reports 把各分片的 blob 汇总成一份完整报告(可输出 json/junit 等格式),供 CI 判定与归档。这套模式让"结果完整"与"执行并行"两全:任何一个分片失败都能被合并后的总报告捕获。
7. 测试顺序控制:sequence 与 Shuffle
7.1 sequence 配置
defineConfig({
test: {
sequence: {
// Run tests in random order
shuffle: true,
// Seed for reproducible shuffle
seed: 12345,
// Hook execution order
hooks: 'stack', // 'stack', 'list', 'parallel'
// All tests concurrent by default
concurrent: true,
},
},
})
参数含义:
shuffle: true:文件与测试按随机顺序执行,是发现隐藏顺序依赖(测试 A 污染了测试 B 依赖的状态)最有效的探针;seed:随机种子。固定 seed 后 shuffle 结果可复现——CI 上报了 shuffle 导致的失败,本地用同一 seed 即可稳定复现,排查完再换 seed 继续"随机轰炸";hooks:控制beforeAll/beforeEach等 hook 的执行顺序,'stack'(默认,后注册的先执行,与嵌套语义一致)、'list'(注册顺序执行)、'parallel';concurrent:在 sequence 层面全局开启并发,效果类似给所有测试加.concurrent,属于激进配置,仅在确认无共享状态时使用。
7.2 局部 shuffle
不想要全局随机时,可以只对单个 suite 或一次运行开 shuffle:
// Via CLI
vitest --sequence.shuffle
// Per suite
describe.shuffle('random order', () => {
test('test 1', () => {})
test('test 2', () => {})
test('test 3', () => {})
})
describe.shuffle 只打乱该 suite 内测试的顺序,适合"怀疑某几个测试互相依赖"的定向排查;vitest --sequence.shuffle 则是对一次本地运行做整体随机验证。
8. Pool 选型:threads / forks / vmThreads
pool 决定 worker 的技术载体,三种选项的特性与配置如下:
8.1 threads(默认)
基于 worker_threads,创建快、共享进程内存,是默认选择:
defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
maxThreads: 8,
minThreads: 2,
isolate: true,
},
},
},
})
isolate: true(默认)时每个文件在独立 worker 实例中运行(即 5.2 节说的文件隔离),maxThreads/minThreads 等价于该 pool 内的 worker 上下限。
8.2 forks
子进程隔离,进程边界最硬——native 模块崩溃、C++ 扩展污染等问题在线程池里会互相传染,切到 forks 即可消除;代价是进程创建与通信开销更大:
defineConfig({
test: {
pool: 'forks',
poolOptions: {
forks: {
maxForks: 4,
isolate: true,
},
},
},
})
参考文档的定性很直白:"Better isolation, slower"。选型建议:纯 TS/JS 的库和前端应用(如本仓库的 studio、ui 包)保持默认 threads 即可;一旦测试加载了 native 依赖或出现线程间幽灵污染,优先尝试 forks。
8.3 vmThreads
每个文件跑在独立的 Node vm 上下文里,隔离粒度最细(文件间连 globalThis 都不共享),适合"同一个文件内多个环境都要干净"的极端场景:
defineConfig({
test: {
pool: 'vmThreads',
},
})
9. --bail:失败即停
在调试阶段,并行 + 全量跑会淹没第一个真正的错误,--bail 让测试在累计 N 次失败后停止:
vitest --bail 1 # Stop after 1 failure
vitest --bail # Stop on first failure (same as --bail 1)
--bail 1 是最常用的调试姿态:第一个失败即终止后续调度,把并行运行降级为"快速定位首个坏点",修完再放开。
10. 要点速查与落地建议
参考文档末尾的 Key Points,结合本仓库实践归纳如下:
| 能力 | 开关 / API | 默认行为 | 适用场景 |
|---|---|---|---|
| 文件级并行 | fileParallelism |
true,自动启用 |
所有仓库默认受益 |
| worker 数量 | maxWorkers / minWorkers |
按 CPU 推断 | 限制 CI 容器内存占用 |
| 文件内并发 | test.concurrent / describe.concurrent |
关闭,需显式声明 | 独立 I/O 密集测试(如 functions.test.ts) |
| 并发中强制串行 | test.sequential / describe.sequential |
— | 共享状态的少数测试 |
| 文件内并发上限 | maxConcurrency |
5 | 控制对下游服务的并发压力 |
| 文件隔离 | isolate |
true,建议保持 |
追求极致速度且确认无状态耦合时才关 |
| 分片 | vitest run --shard=i/n |
— | CI 多机/多 job 分摊 |
| 报告合并 | --reporter=blob + --merge-reports |
— | 分片结果汇总为单一报告 |
| 顺序随机化 | sequence.shuffle / seed / describe.shuffle |
关闭 | 挖掘隐藏顺序依赖 |
| 执行池 | pool: 'threads' | 'forks' | 'vmThreads' |
threads |
按隔离需求与性能权衡选型 |
| 失败即停 | --bail [n] |
关闭 | 调试阶段快速定位 |
给本仓库(或同构 pnpm + Vitest monorepo)的实操建议:
- 什么都不配也能并行:文件级并行默认开启,本仓库七个 vitest.config.ts 均未显式配置
fileParallelism/pool,即全部运行在默认 threads 并行之下; - 并发标注要保守:只对"相互独立 + I/O 密集"的测试加
.concurrent,并一律使用上下文expect——packages/ai-commands 的用例是标准范本; - 用 shuffle 验证隔离假设:定期
vitest --sequence.shuffle跑一轮,若失败率上升,说明存在顺序依赖,回到isolate: true语义下逐个隔离修复; - flaky 治理靠 retry 分层:参考 studio 配置 的
retry: IS_CI ? 2 : 0,把抖动吸收留在 CI,本地保持零容忍。
以上配置与 API 均出自技能参考文档 features-concurrency.md(由 Vitest 官方文档生成,基于 Vitest 3.x),并已在当前仓库的 pnpm-workspace.yaml(vitest: ^4.1.4)与各子包配置中验证了实际应用形态;具体默认值随版本可能有差异,落地时以你所安装版本的官方文档为准。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00