Vitest Hooks 完全指南:测试生命周期钩子的使用、顺序与源码级原理
【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest
Vitest 提供了一组用于接入测试生命周期的 Hooks 函数(beforeEach、afterEach、beforeAll、afterAll、aroundEach、aroundAll、onTestFinished、onTestFailed),帮助你在测试前后统一执行 setup 与 teardown 逻辑,避免重复样板代码。本文以 Vitest 仓库中 docs/api/hooks.md 为主线,结合 hooks.ts 与 run.ts 的源码实现,系统讲解每个钩子的签名、作用域、超时配置、执行顺序(sequence.hooks)以及它们在测试、套件与并发场景下的实际行为,读完即可在自己的测试项目中正确编排生命周期逻辑。
Hooks 的基本概念与作用域
Hooks 允许你接入测试的生命周期,从而避免重复的 setup 和 teardown 代码。它们作用于当前上下文:
- 在文件顶层使用时,作用于整个文件(File 级别);
- 在
describe块内使用时,作用于当前 suite。
需要注意的是,当 Vitest 以 类型检查器 模式运行时(typecheck.enabled),这些 Hooks不会被调用——因为类型检查并不真正执行测试体。
默认情况下,测试钩子以栈(stack)顺序调用,即 "before" 类钩子按注册顺序执行,而 "after" 类钩子按注册顺序的逆序执行。该行为可以通过sequence.hooks配置项调整(详见下文「执行顺序:sequence.hooks」一节)。
从源码看,所有钩子都通过getCurrentSuite().on(name, hook)注册到当前 suite 上(见 hooks.ts),运行时再由 runner 从对应 suite 中取出并按序调用。钩子回调若返回 Promise,Vitest 会等待其 resolve 后再继续;此外注册时会对回调做assertTypes校验,传入非函数会直接报错。
beforeEach 与 afterEach:每个测试的前置与清理
beforeEach
function beforeEach( body: (context: TestContext) => unknown, timeout?: number, ): void注册一个回调,在当前 suite 中每个测试运行前被调用。如果函数返回 Promise,Vitest 会等待其 resolve 后才开始运行测试。
可选地,你可以传入一个timeout(毫秒),指定等待多长时间后终止。默认值为 10 秒,可通过全局配置hookTimeout修改。
import { beforeEach } from 'vitest' beforeEach(async () => { // 在每个测试运行前清除 mock 并添加一些测试数据 await stopMocking() await addUser({ name: 'John' }) })这里的beforeEach保证了每个测试运行前用户数据已就绪。
beforeEach还可以返回一个可选的清理函数,其语义类似于afterEach,唯一的区别是:它会在所有其他afterEach钩子执行完之后才运行:
import { beforeEach } from 'vitest' beforeEach(async () => { // 每个测试运行前调用一次 await prepareSomething() // 清理函数:每个测试运行后调用一次,位于所有 afterEach 钩子之后 return async () => { await resetSomething() } })这一机制在源码中由getBeforeHookCleanupCallback实现(hooks.ts):当钩子返回值为函数类型时,会将其包装进带超时的清理回调;beforeEach注册时还通过CLEANUP_TIMEOUT_KEY单独记录清理阶段的超时时间,并保留原始堆栈以辅助报错定位。
afterEach
function afterEach( body: (context: TestContext) => unknown, timeout?: number, ): void注册一个回调,在当前 suite 中每个测试完成后被调用。如果函数返回 Promise,Vitest 会等待其 resolve 后再继续。同样支持可选的timeout(毫秒),默认 10 秒,可全局配置。
import { afterEach } from 'vitest' afterEach(async () => { await clearTestingData() // 每个测试运行后清除测试数据 })提示:你还可以在测试执行过程中使用
onTestFinished在测试结束后清理状态,详见后文。
关于父级 suite 的传递:beforeEach/afterEach会沿父链向上传递执行。在callSuiteHook的实现中(run.ts),beforeEach会先递归执行父 suite 的钩子,再执行当前 suite 的;而afterEach则相反,先执行当前 suite 的,再递归执行父 suite 的,从而保证「外层先 setup、后 teardown」的语义。
beforeAll 与 afterAll:整个套件的开始与收尾
beforeAll
function beforeAll( body: (context: ModuleContext) => unknown, timeout?: number, ): void注册一个回调,在当前 suite 中所有测试开始运行前被调用一次。如果函数返回 Promise,Vitest 会等待其 resolve 后再运行测试。可选timeout(毫秒),默认 10 秒,可全局配置。
import { beforeAll } from 'vitest' beforeAll(async () => { await startMocking() // 所有测试运行前调用一次 })beforeAll同样可以返回清理函数,语义类似afterAll,区别在于它会在所有其他afterAll钩子执行完之后才运行:
import { beforeAll } from 'vitest' beforeAll(async () => { // 所有测试运行前调用一次 await startMocking() // 清理函数:所有测试运行后调用一次,位于所有 afterAll 钩子之后 return async () => { await stopMocking() } })afterAll
function afterAll( body: (context: ModuleContext) => unknown, timeout?: number, ): void注册一个回调,在当前 suite 中所有测试运行完成后被调用一次。如果函数返回 Promise,Vitest 会等待其 resolve 后再继续。可选timeout(毫秒),默认 10 秒,可全局配置。
import { afterAll } from 'vitest' afterAll(async () => { await stopMocking() // 该方法会在所有测试运行后被调用 })从 run.ts 可以看出,afterAll被放在finally块中执行——即使beforeAll或某个子测试失败,afterAll依然会被调用,确保资源总能被释放;同时beforeAll返回的清理函数会在afterAll之后统一执行。
aroundEach:将每个测试包裹进一个上下文
function aroundEach( body: ( runTest: () => Promise<void>, context: TestContext, ) => Promise<void>, timeout?: number, ): void注册一个回调,包裹当前 suite 中的每个测试。回调接收一个runTest函数,你必须调用它来真正运行测试。
runTest()会依次执行beforeEach钩子、测试本身、测试中访问的 fixtures,以及afterEach钩子。在aroundEach回调中访问的 fixtures,会在runTest()被调用之前完成初始化,并在 aroundEach 的 teardown 代码完成后被销毁,因此你可以在 setup 与 teardown 两个阶段安全地使用它们。
警告:你必须在回调中调用
runTest()。如果runTest()未被调用,测试将报错失败。
可选地,你可以提供timeout(毫秒)。该超时分别独立地作用于 setup 阶段(runTest()之前)与 teardown 阶段(runTest()之后)。默认 10 秒,可全局配置。
import { aroundEach, test } from 'vitest' aroundEach(async (runTest) => { await db.transaction(runTest) }) test('insert user', async () => { await db.insert({ name: 'Alice' }) // 测试结束后事务被自动回滚 })何时使用
aroundEach当你的测试需要运行在包裹它的上下文中时,使用aroundEach,例如:
- 将测试包裹进 AsyncLocalStorage 上下文
- 用 tracing spans 包裹测试
- 数据库事务
如果只是需要在测试前后运行代码,优先使用
beforeEach+ 返回清理函数的方式:beforeEach(async () => { await database.connect() return async () => { await database.disconnect() } })
多个 aroundEach 钩子的嵌套
当注册了多个aroundEach钩子时,它们会互相嵌套,第一个注册的钩子位于最外层:
aroundEach(async (runTest) => { console.log('outer before') await runTest() console.log('outer after') }) aroundEach(async (runTest) => { console.log('inner before') await runTest() console.log('inner after') }) // 输出顺序: // outer before // inner before // test // inner after // outer after这一行为在 e2e 测试 around-each.test.ts 中有直接验证,其断言输出恰好为outer before → inner before → test → inner after → outer after。
上下文与 Fixtures
回调的第二个参数是测试上下文(TestContext),这意味着你可以在aroundEach中使用 fixtures:
import { aroundEach, test as base } from 'vitest' const test = base.extend<{ db: Database; user: User }>({ db: async ({}, use) => { // db 在 `aroundEach` 钩子之前创建 const db = await createTestDatabase() await use(db) await db.close() }, user: async ({ db }, use) => { // `user` 作为事务的一部分运行 // 因为它在 `test` 内部被访问 const user = await db.createUser() await use(user) }, }) // 注意:`aroundEach` 也挂载在 test 上 // 以便获得更好的 TypeScript 类型支持 test.aroundEach(async (runTest, { db }) => { await db.transaction(runTest) }) test('insert user', async ({ db, user }) => { await db.insert(user) })源码层面,aroundEach的实现(hooks.ts)通过withFixtures将 fixtures 注入回调,并经由callAroundEachHooks(run.ts)在调用runTest之前取得 fixture 清理的"检查点"(getFixtureCleanupCount),从而区分「为 aroundEach 创建的 fixtures」与「测试内部创建的 fixtures」,确保各自在正确的时机被清理。
aroundAll:将整个套件包裹进一个上下文
function aroundAll( body: ( runSuite: () => Promise<void>, context: ModuleContext, ) => Promise<void>, timeout?: number, ): void注册一个回调,包裹当前 suite 中的所有测试。回调接收一个runSuite函数,你必须调用它来运行套件内的测试。
runSuite()会运行套件内的所有测试,包括beforeAll/afterAll/beforeEach/afterEach钩子、aroundEach钩子以及 fixtures。
警告:你必须在回调中调用
runSuite()。如果runSuite()未被调用,该钩子会报错失败,并且套件内的所有测试都会被跳过。这一点在 run.ts 中有对应实现:当钩子执行完毕但use(即runSuite)未被调用时,会抛出AroundHookSetupError;而在 runSuite 的 catch 分支中,若 suite 从未真正运行(!suiteRan),会调用markTasksAsSkipped将全部任务标记为 skip。
可选地,你可以提供timeout(毫秒)。该超时分别独立地作用于 setup 阶段(runSuite()之前)与 teardown 阶段(runSuite()之后)。默认 10 秒,可全局配置。
import { aroundAll, test } from 'vitest' aroundAll(async (runSuite) => { await tracer.trace('test-suite', runSuite) }) test('test 1', () => { // 在 tracing span 内运行 }) test('test 2', () => { // 也在同一个 tracing span 内运行 })何时使用
aroundAll当你的套件需要运行在包裹所有测试的上下文中时,使用aroundAll,例如:
- 将整个套件包裹进 AsyncLocalStorage 上下文
- 用 tracing spans 包裹套件
- 数据库事务
如果只是需要在所有测试前后各运行一次代码,优先使用
beforeAll+ 返回清理函数的方式:beforeAll(async () => { await server.start() return async () => { await server.stop() } })
多个 aroundAll 钩子的嵌套
多个aroundAll钩子同样互相嵌套,第一个注册的位于最外层:
aroundAll(async (runSuite) => { console.log('outer before') await runSuite() console.log('outer after') }) aroundAll(async (runSuite) => { console.log('inner before') await runSuite() console.log('inner after') }) // 输出顺序:outer before → inner before → tests → inner after → outer after套件间的嵌套关系
每个 suite 拥有自己独立的aroundAll钩子,父 suite 的aroundAll会包裹子 suite 的执行:
import { AsyncLocalStorage } from 'node:async_hooks' import { aroundAll, describe, test } from 'vitest' const context = new AsyncLocalStorage<{ suiteId: string }>() aroundAll(async (runSuite) => { await context.run({ suiteId: 'root' }, runSuite) }) test('root test', () => { // context.getStore() 返回 { suiteId: 'root' } }) describe('nested', () => { aroundAll(async (runSuite) => { // 在这里可以访问父级上下文 await context.run({ suiteId: 'nested' }, runSuite) }) test('nested test', () => { // context.getStore() 返回 { suiteId: 'nested' } }) })测试级 Hooks:onTestFinished 与 onTestFailed
Vitest 还提供了几个可以在测试执行过程中调用的钩子,用于在测试结束时清理状态。
警告:这些钩子如果在测试体外被调用,会抛出错误。源码
createTestHook(hooks.ts)通过getCurrentTest()检查当前是否存在运行中的测试,不存在时抛出Hook onTestFinished() can only be called inside a test。
onTestFinished
该钩子在测试运行结束后总是被调用,无论测试通过还是失败。它位于afterEach钩子之后调用——因为afterEach可能影响测试结果。它接收的TestContext对象与beforeEach/afterEach相同。
import { onTestFinished, test } from 'vitest' test('performs a query', () => { const db = connectDb() onTestFinished(() => db.close()) db.query('SELECT * FROM users') })警告:如果测试以并发方式运行(
test.concurrent),你应该始终从测试上下文中获取onTestFinished,因为 Vitest 不会在全局钩子中追踪并发测试:import { test } from 'vitest' test.concurrent('performs a query', ({ onTestFinished }) => { const db = connectDb() onTestFinished(() => db.close()) db.query('SELECT * FROM users') })
该钩子在创建可复用逻辑时尤其有用——比如把一个"返回测试数据库连接"的辅助函数单独抽离到文件中,让每个测试无需关心关闭连接:
// 这个可以放在单独的文件中 function getTestDb() { const db = connectMockedDb() onTestFinished(() => db.close()) return db } test('performs a user query', async () => { const db = getTestDb() expect( await db.query('SELECT * from users').perform() ).toEqual([]) }) test('performs an organization query', async () => { const db = getTestDb() expect( await db.query('SELECT * from organizations').perform() ).toEqual([]) })它也是清理 spy 的良好实践,避免 spy 泄漏到其他测试。你可以通过全局启用restoreMocks配置,或者在onTestFinished内恢复 spy——如果在测试末尾直接恢复 mock,一旦某个断言失败,恢复代码就不会执行;使用onTestFinished则能确保代码总是运行:
import { onTestFinished, test } from 'vitest' test('performs a query', () => { const spy = vi.spyOn(db, 'query') onTestFinished(() => spy.mockClear()) db.query('SELECT * FROM users') expect(spy).toHaveBeenCalled() })提示:
onTestFinished钩子总是以逆序调用,且不受sequence.hooks配置的影响。从源码 run.ts 可以看到,它通过callTestHooks(runner, test, test.onFinished!, 'stack')以固定的'stack'模式调用,与全局序列配置无关。
onTestFailed
该钩子仅在测试失败后被调用。它同样位于afterEach钩子之后调用(因为afterEach可能影响测试结果),接收TestContext对象。该钩子主要用于调试:
import { onTestFailed, test } from 'vitest' test('performs a query', () => { const db = connectDb() onTestFailed(({ task }) => { console.log(task.result.errors) }) db.query('SELECT * FROM users') })警告:如果测试以并发方式运行,你应该始终从测试上下文中获取
onTestFailed:import { test } from 'vitest' test.concurrent('performs a query', ({ onTestFailed }) => { const db = connectDb() onTestFailed(({ task }) => { console.log(task.result.errors) }) db.query('SELECT * FROM users') })
在源码 run.ts 中,onTestFailed的调用发生在onTestFinished之后,且只有当测试结果为 fail 时才触发;它遵循runner.config.sequence.hooks的序列配置(默认stack逆序)。
超时配置:hookTimeout
所有钩子的默认超时均为 10 秒(毫秒),可以通过hookTimeout全局配置:
- 类型:
number - 默认值:Node.js 环境下为
10_000;当browser.enabled为true时为30_000 - CLI:
--hook-timeout=10000或--hookTimeout=10000 - 设为
0可完全禁用超时。
在源码中,默认值来自getDefaultHookTimeout()(hooks.ts),即getRunner().config.hookTimeout;每个钩子也允许通过第二个参数单独覆盖。对于aroundEach/aroundAll,超时通过AROUND_TIMEOUT_KEY记录(hooks.ts),并由callAroundHooks(run.ts)在 setup 与 teardown 两个阶段分别起独立的计时器,任一阶段超时都会抛出对应的AroundHookSetupError/AroundHookTeardownError。
执行顺序:sequence.hooks
钩子的执行顺序由sequence.hooks配置项控制:
- 类型:
'stack' | 'list' | 'parallel' - 默认值:
'stack' - CLI:
--sequence.hooks=<value>
三种模式的行为:
| 模式 | 行为 |
|---|---|
stack | "after" 钩子按逆序执行,"before" 钩子按定义顺序执行 |
list | 所有钩子按定义顺序执行 |
parallel | 同一组钩子并行执行(父 suite 的钩子仍先于当前 suite 的钩子运行),实际并行数受maxConcurrency限制 |
提示:该选项不影响
onTestFinished,它总是以逆序调用。
对应到源码:getSuiteHooks(run.ts)在stack模式下对afterAll/afterEach执行hooks.slice().reverse();callTestHooks(run.ts)在parallel模式下通过Promise.all与limitMaxConcurrency并发执行钩子,其余模式则逐个顺序执行。callCleanupHooks对beforeEach/beforeAll返回的清理函数也遵循同样的序列规则(run.ts)。
在配置文件中使用示例:
import { defineConfig } from 'vitest/config' export default defineConfig({ test: { sequence: { hooks: 'list', // 让所有钩子都按定义顺序执行 }, }, })或通过 CLI 覆盖:
npx vitest --sequence.hooks=list小结:如何选择合适的钩子
| 需求 | 推荐钩子 |
|---|---|
| 每个测试前准备数据 / 后清理数据 | beforeEach+afterEach,或beforeEach返回清理函数 |
| 整个套件只执行一次 setup / teardown | beforeAll+afterAll,或beforeAll返回清理函数 |
| 每个测试需要运行在某个上下文(事务、AsyncLocalStorage、trace span)中 | aroundEach |
| 整个套件需要运行在某个上下文(共享 span、整库事务)中 | aroundAll |
| 在测试内部动态注册清理逻辑(可复用工具函数、spy 恢复) | onTestFinished |
| 测试失败后的调试与诊断 | onTestFailed |
全部 8 个钩子的签名与示例均可直接在 docs/api/hooks.md 查阅,其运行时行为可在 hooks.ts(注册实现)与 run.ts(执行调度)中追踪验证,e2e 行为断言可参考 around-each.test.ts。
【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考