返回博客

Vitest 并行跑测试,行消失了——fileParallelism 与共享 DB 行的竞争陷阱

单文件跑测试,全绿。整套跑,稳定炸 4 个。报错是 expected null to be '{"cookies":[...]}' 或者 expected 404 to be 200——数据明明刚写进去,下一行断言就消失了。

直觉第一反应是"异步时序问题",于是加 await、加 sleep,折腾半天无果。直到把两个出问题的测试文件放在一起单独跑,才复现——问题出在文件之间,不是文件内部。

现象

  • npx vitest run path/to/A.test.ts → 9/9 PASS
  • npx vitest run path/to/B.test.ts → 9/9 PASS
  • npx vitest run(全套)→ 4/9 FAIL,报 AssertionError: expected null to be '...'

失败的测试散落在两个不同模块的测试文件里,表面上毫无关联。

根因

Vitest 默认 fileParallelism: true——多个测试文件并发执行。

项目里两个测试文件恰好都依赖同一条「种子行」(一个管理员账号,由初始化脚本写入,所有文件复用同一个 ADMIN_USER_ID)。

  • 文件 A(cookies-store.test.ts):beforeEach 里执行 hardDelete(ADMIN_USER_ID) 清理现场
  • 文件 B(credentials.test.ts):先 PUT 写入 cookies,再 GET 断言内容存在

两文件并发运行时,时序如下:

文件 B: PUT /credentials  → 写入 cookies 行 ✅
文件 A: beforeEach        → DELETE WHERE user_id = ADMIN_USER_ID 💥
文件 B: GET /credentials  → 读到 null → AssertionError ❌

文件 A 的清理逻辑在文件 B 的断言之前执行,将刚写入的行删掉了。两边都"正确",却互相踩踏。

坑:pool 配置无法解决这个问题

直觉上会想到 pool: 'forks' + poolOptions.forks.singleFork: true——把所有文件放进同一个 fork 里跑。但这只改变了进程隔离,文件之间仍然并发执行。beforeEach 依然会与另一个文件的请求交错,竞态条件不消失。

真正需要的是文件串行执行,不是进程合并。

正解

在项目根的 vitest.config.ts 中加一行:

import { defineConfig } from 'vitest/config'
 
export default defineConfig({
  test: {
    fileParallelism: false,
  },
})

fileParallelism: false 会让 Vitest 按顺序逐文件执行,文件 A 跑完再跑文件 B,beforeEach 永远不会在另一个文件的测试中途触发。

加完配置后:

npx vitest run
# 9/9 PASS ✅
正解:fileParallelism: false 串行化文件执行

一行配置解决整类竞态。代价是测试套件墙钟时间约变慢 2 倍,但完全消除「跨文件共享行」这一类 flake。

更彻底的长期方案:每文件独立 fixture 行

ADMIN_USER_ID 从全局种子改为每个文件用 runId-suffixed UUID 自己插入、afterAll 自己清理。好处是文件可以重新开启并行。代价是每个文件要写 ~30 行测试基础设施(FK 链插入 + teardown)。

对于只需要 1 个管理员账号的小项目,fileParallelism: false 是 ROI 最高的选择。

一句话外卖

测试单跑全绿、合跑必炸——先查「是否有多个文件共用同一条数据库行」,fileParallelism: false 是最小代价的止血手段。