Vitest 数据库事务逐测试隔离:用 aroundEach 和 Scoped Fixture 实现零清理集成测试
【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest
集成测试要访问真实数据库,就必须保证每个测试都从干净状态开始。逐个测试 TRUNCATE 表太慢,而 Vitest 官方给出的答案是:用aroundEach钩子(4.1.0 引入)把每个测试包进一个数据库事务、测试结束即回滚,再配合作用域 fixture(3.2.0 引入)管理连接的生命周期。本文基于 Vitest 官方的 Database Transaction per Test 配方展开,既完整给出可复制的落地代码,也结合 运行器源码 讲清aroundEach的包装时序、fixture 检查点清理机制,以及关闭文件隔离(isolate: false)后 worker 级连接复用的原理与代价。
问题背景:为什么不用 TRUNCATE,而用事务回滚
每个测试前清空表(TRUNCATE / DELETE)有两个硬伤:一是锁表和 IO 开销随测试数量线性放大;二是你需要为每张表编写清理逻辑,跨表外键还要按依赖顺序清。事务回滚方案则完全不同:
- 每个测试开始时
BEGIN,结束时ROLLBACK,全程没有数据真正落库; - 不需要逐测试写清理 SQL,回滚天然恢复全部状态;
- 测试之间互不污染,即使某个测试写坏了数据也不影响下一个测试。
Vitest 从 4.1 起提供了aroundEach钩子,它接收一个必须调用的runTest函数,把测试执行“夹”在自己的 setup 与 teardown 之间;3.2 起提供的 fixture 作用域(scope: 'file'/scope: 'worker')则让连接可以跨测试共享而不重复建立。两者组合,就是本方案的全部骨架。
完整模式:file 级连接 + 逐测试事务
import { test as baseTest } from 'vitest' import { createTestDatabase } from './db.ts' export const test = baseTest .extend('db', { scope: 'file' }, async ({}, { onCleanup }) => { const db = await createTestDatabase() onCleanup(() => db.close()) return db }) test.aroundEach(async (runTest, { db }) => { await db.transaction(runTest) }) test('insert user', async ({ db }) => { await db.insert({ name: 'Alice' }) // 测试结束时自动回滚 })要点拆解:
db是scope: 'file'的 fixture:连接在每个测试文件里只建立一次,onCleanup注册的db.close()在文件跑完后执行。注意onCleanup每个 fixture 只能调用一次(见 Test Context 文档),多个清理动作要么合并成一个函数,要么拆成多个 fixture。test.aroundEach而不是全局aroundEach:扩展过的test对象自带类型安全的钩子(beforeEach/afterEach/aroundEach/beforeAll/afterAll/aroundAll),第二个参数能拿到扩展后的 fixture 上下文。用全局beforeAll之类的函数是拿不到自定义 fixture 的,会直接得到undefined。db.transaction(runTest):把runTest当作“事务主体”传给驱动层。runTest内部会执行beforeEach钩子、测试本身、测试内访问的 fixture 以及afterEach钩子;当它 resolve(无论测试通过还是失败)后,db.transaction的收尾代码执行ROLLBACK。测试函数只从上下文解构db,对它运行在事务中毫无感知。
这个模式的前提是你的数据库驱动支持嵌套事务或 savepoint——大多数现代数据库(PostgreSQL、MySQL、SQLite 等)都满足。
仓库自带的 e2e 测试 around-each.test.ts 中有一个aroundEach with database transaction pattern用例,用内存对象模拟了同一套逻辑:beginTransaction→runTest()→finally里无条件rollback(),并断言第二个测试看到的db.data是空数组——这正是“回滚保证下一测试干净状态”的最小验证。
原理剖析:aroundEach 在运行器中如何包装测试
阅读 packages/vitest/src/runtime/runner/hooks.ts 中的aroundEach实现,可以看到它做的事比“前后各跑一段代码”要精细:
export function aroundEach<ExtraContext = object>( fn: AroundEachListener<ExtraContext>, timeout?: number, ): void { assertTypes(fn, '"aroundEach" callback', ['function']) const stackTraceError = new Error('STACK_TRACE_ERROR') const resolvedTimeout = timeout ?? getDefaultHookTimeout() const wrapper: AroundEachListener<ExtraContext> = (runTest, context, suite) => { const innerFn = (ctx: any) => fn(runTest, ctx, suite) configureProps(innerFn, { index: 1, original: fn }) const fixtureResolver = withFixtures(innerFn, { suite }) return fixtureResolver(context) } // ... 注册到当前 suite 的 'aroundEach' 钩子表 }几个值得注意的细节:
- fixture 注入发生在
runTest之前:withFixtures包装器会按aroundEach回调里解构的属性(如{ db })提前解析依赖。源码注释与 Hooks 文档 均说明:在aroundEach回调中访问的 fixture 会在runTest()被调用前初始化、在 aroundEach teardown 完成后才销毁——所以你可以在 setup 和 teardown 两个阶段安全地使用它。 runTest必须且只能调用一次:run.ts 中的callAroundHooks会对use()(即runTest)的调用做状态机管理——未调用会抛AroundHookSetupError,重复调用会抛AroundHookMultipleCallsError。e2e 测试对这两种情况都有快照断言(见 around-each.test.ts)。- setup 与 teardown 有独立超时:
timeout参数(缺省取hookTimeout,默认 10 秒)对runTest()之前和之后两个阶段分别计时。超时分别抛出AroundHookSetupError/AroundHookTeardownError,且 setup 超时还会通过abortContextSignal中止测试上下文。对事务场景来说这意味着:即使回滚语句卡住,Vitest 也会按超时把测试判为失败,而不是让整个 worker 挂死。
执行顺序与 fixture 检查点
从 run.ts 的runTest流程 看,单个测试的完整执行链是:
callAroundEachHooks(所有 aroundEach,最外层先注册) └─ runTest(fixtureCheckpoint) ├─ beforeEach 钩子 ├─ 测试函数体 ├─ afterEach 钩子 ├─ beforeEach 返回的清理函数 └─ callFixtureCleanupFrom(context, checkpoint) // 只清 runTest 内创建的 fixture其中fixtureCheckpoint是 run.ts 第 623-627 行 传入的清理函数计数快照,配合 fixture.ts 的callFixtureCleanupFrom实现分层清理:
runTest()内部(测试体和beforeEach/afterEach)创建的 test 级 fixture,在runTest返回后立即清理——此时仍在aroundEach的事务范围内,回滚还能覆盖到;aroundEach回调自身访问的 fixture(比如db)则在全部 aroundEach 层 teardown 完成后才清理(run.ts 第 717-724 行),确保连接不会在回滚执行前被关闭。
另外,e2e 测试还验证了几个对本模式有用的行为:aroundEach在测试失败时 teardown 依然执行(回滚不会因断言失败被跳过)、retry场景下每轮重试都会重新进入aroundEach(即每个 attempt 都有独立事务)。
多钩子嵌套
注册多个aroundEach时,先注册的在最外层。around-each.test.ts 的断言顺序是outer before → inner before → test → inner after → outer after。这意味着一个常见且合理的组合:外层aroundEach开事务回滚,内层aroundEach再包一层AsyncLocalStorage.run({...}, runTest),把租户 ID、trace ID 之类的上下文与事务一起传播给测试内部——官方配方明确提示了这种用法。而 Hooks 文档 给出的选型原则是:需要“把测试包进一个上下文”(事务、tracing span、AsyncLocalStorage)才用aroundEach;只是前后各跑一段清理的话,beforeEach+ 返回清理函数更合适。
进阶:一个 worker 只开一条连接
上面的scope: 'file'方案里,每个测试文件都要建立一次数据库连接。文件数量多时,连接建立成本会累积。解法是把 fixture 改成scope: 'worker'并关闭文件隔离:
import { defineConfig } from 'vitest/config' export default defineConfig({ test: { isolate: false, }, })import { test as baseTest } from 'vitest' import { createTestDatabase } from './db.ts' export const test = baseTest .extend('db', { scope: 'worker' }, async ({}, { onCleanup }) => { const db = await createTestDatabase() onCleanup(() => db.close()) return db }) test.aroundEach(async (runTest, { db }) => { await db.transaction(runTest) })行为差异来自 worker 复用策略:
- 默认(
isolate: true):每个测试文件独占一个 worker,文件即 worker 生命周期,所以scope: 'file'与scope: 'worker'效果相同。 isolate: false时:Vitest 在maxWorkers上限内跨文件复用 worker,worker 级 fixture 就从“每文件一次”变成“每 worker 一次”。官方配方给出的量化例子是:200 个文件跑在 8 个 worker 上,连接数从 200 降到 8。
从源码看,作用域判定在 fixture.ts 的parseUserFixtures:
if (item.scope === 'worker' && (runner.pool === 'vmThreads' || runner.pool === 'vmForks')) { item.scope = 'file' }即vmThreads和vmForks池无视isolate标志始终隔离运行,worker 级 fixture 会自动降级为 file 级(详见 isolate 配置文档)。所以“每 worker 一条连接”的优化只在threads/forks池生效。
代价:模块状态会在文件间泄漏
复用 worker 不是免费的优化。关闭隔离后,同一 worker 内先后运行的文件共享模块实例:顶层计数器、缓存、被 monkey-patch 的全局对象,都可能从上一个文件泄漏给下一个文件。数据库层的逐测试回滚只能保证数据隔离,管不了 worker 内的模块状态隔离。
Per-File Isolation 配方 给出了安全关闭隔离的判据——文件满足以下全部条件才“安全”:
- 不修改模块级状态(计数器、缓存、顶层
let); - 不调用
vi.stubGlobal/vi.stubEnv; - 不 monkey-patch 原型(
Date.prototype、Array.prototype等); - 不往
process等长生命周期 emitter 上注册监听器; - 不依赖全新的模块实例来驱动
vi.mock工厂。
它同时给出了验证手段:vitest --shuffle --run连跑两遍,若结果不同说明存在顺序依赖。更稳妥的折中是用projects按项目拆分配置——集成/单元套件关闭隔离换取速度,真正需要隔离的测试保持默认,不必全仓一刀切。
常见变体与注意事项
db.transaction的失败路径:runTest抛出(测试失败)时事务必须回滚。如果你的驱动transaction(runner)API 不保证失败时回滚,请显式写成try { await runTest() } finally { await db.rollback() }——e2e 测试里的事务模拟正是这个写法。- 并发测试:
aroundEach对并发测试同样生效(e2e 中有concurrent用例)。但并发执行意味着同一 worker 里可能同时存在多个活跃事务,若你的 fixture 是单连接共享的,需确认驱动支持同连接并发事务,或改用连接池 + 每事务独立连接。 - 需要整个套件一个事务时:把
test.aroundEach换成test.aroundAll(async (runSuite, { db }) => db.transaction(runSuite))即可,适合只读校验类测试;代价是套件内所有测试共享一个事务,个别测试若意外提交会污染后续断言。 - fixture 访问方式必须解构:
aroundEach回调的上下文参数要写成({ db })对象解构形式,运行器靠解析函数签名(fixture.ts 的getUsedProps)判断依赖,不写解构会抛FixtureParseError。 - 版本要求:
aroundEach需 Vitest 4.1.0+,fixture 作用域需 3.2.0+,test.override等配套能力需 4.1.0+。
参考文件
| 内容 | 路径 |
|---|---|
| 官方配方原文 | docs/guide/recipes/db-transaction.md |
aroundEach/aroundAllAPI 文档 | docs/api/hooks.md |
fixture 作用域与test.extend | docs/guide/test-context.md |
aroundEach注册实现 | packages/vitest/src/runtime/runner/hooks.ts |
| around 钩子调度与超时状态机 | packages/vitest/src/runtime/runner/run.ts |
| fixture 作用域解析与降级 | packages/vitest/src/runtime/runner/fixture.ts |
| 行为验证(事务回滚、超时、重试) | test/e2e/test/around-each.test.ts |
isolate配置 | docs/config/isolate.md |
| 关闭隔离的判据与验证 | docs/guide/recipes/disable-isolation.md |
【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考