Vitest Hooks 完全指南:测试生命周期钩子的使用、顺序与源码级原理
2026/9/13 12:17:13 网站建设 项目流程

Vitest Hooks 完全指南:测试生命周期钩子的使用、顺序与源码级原理

【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest

Vitest 提供了一组用于接入测试生命周期的 Hooks 函数(beforeEachafterEachbeforeAllafterAllaroundEacharoundAllonTestFinishedonTestFailed),帮助你在测试前后统一执行 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.enabledtrue时为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.alllimitMaxConcurrency并发执行钩子,其余模式则逐个顺序执行。callCleanupHooksbeforeEach/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 / teardownbeforeAll+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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询