首页
/ 深入解析 Vitest 生命周期钩子:beforeEach / aroundEach / test.extend 在 Supabase 仓库中的实战用法

深入解析 Vitest 生命周期钩子:beforeEach / aroundEach / test.extend 在 Supabase 仓库中的实战用法

2026-09-06 23:10:12作者:房伟宁

本篇基于仓库内置的 Vitest 技能参考文档 core-hooks.md(基于 Vitest 3.x,由 SKILL.md 声明)展开,完整覆盖 beforeAll / afterAll / beforeEach / afterEach 四大基础钩子、清理函数返回模式、作用域钩子、钩子超时、around* 环绕钩子、测试级钩子(onTestFinished / onTestFailed)以及 test.extend 扩展钩子的类型感知特性,并结合 monorepo 中 packages/pg-metapackages/commonapps/studioe2e/studio 的真实测试代码印证每一种模式的落地写法。读完后你将掌握:如何在数据库类测试中正确管理 setup/teardown 生命周期、如何用返回清理函数和环绕钩子避免资源泄漏、以及钩子执行顺序(stack/list/parallel)的配置原理。

一、四大基础钩子:文件级与用例级生命周期

Vitest 提供两组钩子:beforeAll / afterAll 在整个文件(或 suite)级别各执行一次,beforeEach / afterEach 在每个 test 用例执行前后各执行一次。文档给出的标准导入与用法如下:

import { afterAll, afterEach, beforeAll, beforeEach, test } from 'vitest'

beforeAll(async () => {
  // Runs once before all tests in file/suite
  await setupDatabase()
})

afterAll(async () => {
  // Runs once after all tests in file/suite
  await teardownDatabase()
})

beforeEach(async () => {
  // Runs before each test
  await clearTestData()
})

afterEach(async () => {
  // Runs after each test
  await cleanupMocks()
})

仓库实证:pg-meta 的 beforeAll/afterAll 数据库清理模式

packages/pg-meta 是仓库中与 Postgres 元数据交互的包,它的测试文件几乎统一采用 beforeAll + afterAll 包住整个数据库生命周期。以 extensions.test.ts 为例:

import { afterAll, beforeAll, expect, test } from 'vitest'
import { cleanupRoot, createTestDatabase } from './db/utils'

beforeAll(async () => {
  // Any global setup if needed
})

afterAll(async () => {
  await cleanupRoot()
})

beforeAll 承担一次性全局初始化(建库、加载依赖),afterAll 统一调用 cleanupRoot() 清理测试数据库,保证测试进程退出前不留残余资源。同目录下 views.test.tsroles.test.tscolumns.test.ts 等文件均是同一范式。

值得注意的是,pg-meta 测试对“每个用例独立的测试数据库”采用了比 beforeEach 更精确的包装函数方案(见 extensions.test.ts):

const withTestDatabase = (
  name: string,
  fn: (db: Awaited<ReturnType<typeof createTestDatabase>>) => Promise<void>
) => {
  test(name, async () => {
    const db = await createTestDatabase()
    try {
      await fn(db)
    } finally {
      await db.cleanup()
    }
  })
}

从源码结构看,这种包装函数将“建库—执行—finally 清理”固化为一个可复用模板,语义上等价于文档第二节的“返回清理函数”模式,但把清理逻辑收敛到了用例内部,即使断言失败也不会跳过 db.cleanup()

仓库实证:common 包的 beforeEach/afterEach 状态重置

单元测试中最常见的用途是状态重置。safe-storage.test.ts 在每个用例前用 beforeEach 构造干净的内存 storage,用 afterEach 统一复位(该文件同时展示了 // @vitest-environment jsdom 文件头注释切环境的用法):

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ...
beforeEach(() => {
  // 为每个用例准备全新的内存 storage 实例
})

afterEach(() => {
  // 复位 vi 定时器 / mock 状态
})

这套模式与 vitest.setup.ts 所在的 packages/common 各测试文件(如 consent-state.test.tstelemetry-first-touch-store.test.ts)保持一致,是 beforeEach/afterEach 最典型的落地场景:让每个用例看到相同初始状态。

二、清理函数返回模式:用 return 替代 after*

当 setup 产生了需要配对释放的资源(服务器、连接)时,为每个资源单独写一个 afterAll/afterEach 容易遗漏或重复。Vitest 允许 before* 钩子直接返回一个清理函数,框架会在对应的 after* 时机自动调用:

beforeAll(async () => {
  const server = await startServer()

  // Returned function runs as afterAll
  return async () => {
    await server.close()
  }
})

beforeEach(async () => {
  const connection = await connect()

  // Runs as afterEach
  return () => connection.close()
})

该模式的核心价值是资源与其清理逻辑在同一个函数体内声明,setup 时拿到的句柄(serverconnection)通过闭包直接传给清理函数,天然规避了“在 after 钩子里重复创建或找不到实例”的问题。文档 Key Points 也明确建议:“Return cleanup function from before* to avoid after* duplication”。

这一思想在 e2e 层有对应体现:e2e/studio/utils/test.ts 提供了 withSetupCleanup 工具,配合 TS 的 using 关键字返回 Symbol.asyncDispose 对象,实现“无论测试成败都执行 cleanup”:

export const withSetupCleanup = async (
  setup: () => Promise<void>,
  cleanup: () => Promise<void>
) => {
  await setup()
  return {
    async [Symbol.asyncDispose]() {
      await cleanup()
    },
  }
}

从源码结构看,它与 Vitest 的 return-cleanup 模式属于同一设计哲学:把“清理时机”交给运行时(框架 / 语言运行时)保证,而不是依赖测试作者的自觉。

三、作用域钩子:外层 describe 钩子向下穿透

钩子不是文件级的全局变量,它绑定在注册它的那个 suite 上,并对该 suite 及其嵌套子 suite 生效。执行顺序为“外层先执行,内层后执行”:

describe('outer', () => {
  beforeEach(() => console.log('outer before'))

  test('test 1', () => {}) // outer before → test

  describe('inner', () => {
    beforeEach(() => console.log('inner before'))

    test('test 2', () => {}) // outer before → inner before → test
  })
})

这意味着在 monorepo 中拆分大型测试文件时,可以在顶层 describe 中放置跨全部子分组共享的重型 setup(例如连接共享 fixture),在内层 describe 中仅叠加该分组专属的轻量 setup,既避免重复代码又不会让无关分组承担不必要的初始化开销。

四、钩子超时:为慢 setup 单独放宽时限

钩子与测试一样有超时约束,且可单独配置——第二个参数传入毫秒数:

beforeAll(async () => {
  await slowSetup()
}, 30_000) // 30 second timeout

这对需要启动真实数据库容器、加载大型 fixture 的 beforeAll 尤其有用:全局测试超时可以保持严格以快速暴露死循环,而个别重量级 setup 通过钩子级超时参数显式放宽,而不是把整个 suite 的超时调松。apps/studiovitest.config.ts 展示了配置层面的稳定性策略与之互补:retry: IS_CI ? 2 : 0(CI 重试 flaky 测试、本地失败立即暴露)——超时参数管“单个钩子允许跑多久”,配置项管“失败后重试几轮”,二者共同构成稳定性预算。

五、Around 钩子:洋葱模型环绕测试

aroundEach / aroundAll 是 Vitest 3.x 引入的环绕钩子:钩子接收一个 runTest()(或 runSuite())回调,回调之前是 setup,之后是 teardown,形成对测试体的“包裹”:

import { aroundEach, test } from 'vitest'

// Wrap each test in database transaction
aroundEach(async (runTest) => {
  await db.beginTransaction()
  await runTest() // Must be called!
  await db.rollback()
})

test('insert user', async () => {
  await db.insert({ name: 'Alice' })
  // Automatically rolled back after test
})

aroundAll:包裹整个 suite

import { aroundAll, test } from 'vitest'

aroundAll(async (runSuite) => {
  console.log('before all tests')
  await runSuite() // Must be called!
  console.log('after all tests')
})

多个 around 钩子的嵌套顺序

注册多个 around 钩子时按洋葱层嵌套,执行顺序为“外层 setup → 内层 setup → 测试 → 内层 teardown → 外层 teardown”:

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')
})

// Order: outer before → inner before → test → inner after → outer after

around 相比 before/after 的独特能力

  1. 必须显式调用 runTest() / runSuite():忘记调用则测试根本不会执行,框架会以错误暴露,防止“注册了包裹但漏写回调”的静默失败;
  2. teardown 与 setup 闭包共享状态:与返回清理函数模式相同,但额外允许在 teardown 中感知 setup 阶段的中间值(如事务 ID、连接的运行状态);
  3. 可条件性执行后续步骤:由于 setup 与 teardown 处于同一异步函数体内,可以写成 try { await runTest() } finally { rollback() },即使测试抛错也保证回滚——这正是数据库事务包裹用例最需要的语义。

六、测试级钩子:onTestFinished 与 onTestFailed

作用在测试体内部的钩子,用于清理在测试运行过程中动态创建的资源:

import { onTestFailed, onTestFinished, test } from 'vitest'

test('with cleanup', () => {
  const db = connect()

  // Runs after test finishes (pass or fail)
  onTestFinished(() => db.close())

  // Only runs if test fails
  onTestFailed(({ task }) => {
    console.log('Failed:', task.result?.errors)
  })

  db.query('SELECT * FROM users')
})
  • onTestFinished:无论用例通过还是失败都执行,是资源释放的首选挂载点;
  • onTestFailed:仅在用例失败时触发,接收 { task } 参数,可用于打印错误上下文、抓取诊断信息。

可复用的清理包装:useTestDb 模式

文档给出的“资源工厂”模式,让资源创建与自动清理封装进一个普通函数:

function useTestDb() {
  const db = connect()
  onTestFinished(() => db.close())
  return db
}

test('query users', () => {
  const db = useTestDb()
  expect(db.query('SELECT * FROM users')).toBeDefined()
})

test('query orders', () => {
  const db = useTestDb() // Fresh connection, auto-closed
  expect(db.query('SELECT * FROM orders')).toBeDefined()
})

每个用例调用 useTestDb() 都获得一条全新连接,且清理已随注册自动绑定——用例代码里不再出现 try/finally。该模式与 packages/pg-metawithTestDatabase 包装函数互为表里:一个靠框架钩子保证清理,一个靠 finally 保证清理,可按资源特性选型。

并发测试中的上下文钩子

test.concurrent 用例,模块级的 onTestFinished 不适用,应使用测试上下文(t 参数)中提供的钩子:

test.concurrent('concurrent', ({ onTestFinished }) => {
  const resource = allocate()
  onTestFinished(() => resource.release())
})

从源码结构看,这符合“并发用例共享执行流,必须通过各自上下文注册清理”的约束:apps/studiovitest.config.tse2e/studio 的 Playwright 用例均属于可能并行的场景,编写此类测试时应一律走上下文钩子。

七、test.extend 与类型感知钩子

通过 test.extend 声明自定义 fixture 后,挂在测试对象上的 beforeEach / afterEach 会获得 fixture 的类型推导:

const test = base.extend<{ db: Database }>({
  db: async ({}, use) => {
    const db = await createDb()
    await use(db)
    await db.close()
  },
})

// These hooks know about `db` fixture
test.beforeEach(({ db }) => {
  db.seed()
})

test.afterEach(({ db }) => {
  db.clear()
})

关键点:

  • fixture 函数本身遵循“use(x) 之前创建、之后清理”的约定,与 around 钩子同构;
  • test.beforeEach(({ db }) => ...) 的回调参数被类型系统推断为 { db: Database },拼错 fixture 名或误用返回值会在编译期报错,而不是运行时 undefined

仓库实证:fixture 的 use 模式在 e2e 层同样成立

e2e/studio/utils/test.ts 用 Playwright 的 base.extend 演示了同一约定:page fixture 通过 addInitScript 预置 localStorage 后调用 await use(page)use 返回后执行清理。虽然 e2e 使用 Playwright 而非 Vitest,但“setup → use() → teardown”的三段式结构与 Vitest fixture 完全一致,印证了这一模式在仓库测试体系中的一致性。

八、钩子执行顺序与 sequence.hooks 配置

Vitest 默认按 stack(栈) 顺序执行钩子:

  1. beforeAll(按注册顺序)
  2. beforeEach(按注册顺序)
  3. 测试本体
  4. afterEach逆序执行)
  5. afterAll逆序执行)

即 setup 正序、teardown 逆序,如同“穿衣服 / 脱衣服”的栈式配对。通过 sequence.hooks 可切换策略:

defineConfig({
  test: {
    sequence: {
      hooks: 'list', // 'stack' (default), 'list', 'parallel'
    },
  },
})

三个取值的适用场景:

  • 'stack'(默认):setup 正序 / teardown 逆序,适合有依赖层次的资源(先启动服务再注入配置,则先撤配置再停服务);
  • 'list':before 与 after 均按注册顺序执行,适合 teardown 顺序与 setup 无关的场景;
  • 'parallel':同组钩子并行执行,最大化 setup/teardown 吞吐,要求钩子间无顺序依赖。

仓库中的 Vitest 配置文件(如 packages/ui/vitest.config.tsapps/studio/vitest.config.ts)均未覆盖 sequence.hooks,即沿用默认 stack 语义——阅读仓库既有测试的钩子时序时可按此默认假设分析。

九、关键要点速查

结合文档 Key Points 与仓库实践,收束为可核查清单:

  • 类型检查阶段不执行钩子:hook 代码不参与 vitest typecheck 的执行,钩子里的逻辑不能靠类型测试覆盖;
  • 能用返回清理函数就不要重复写 after*:setup 与 cleanup 就近声明,降低遗漏风险(仓库对应实例:withSetupCleanupwithTestDatabase);
  • aroundEach / aroundAll 必须调用 runTest() / runSuite():不显式调用则测试 / suite 不会运行;
  • onTestFinished 无论成败都执行:资源释放的唯一安全挂载点;失败诊断用 onTestFailed
  • 并发测试必须使用上下文钩子test.concurrent 内通过 ({ onTestFinished }) 解构注册清理;
  • 时序存疑时先确认 sequence.hooks:默认 stack 语义下,多个 before 正序、多个 after 逆序。

十、适用前提与边界说明

  • 本文所述钩子 API 基于 Vitest 3.x,与仓库 SKILL.md 声明的“based on Vitest 3.x, generated at 2026-01-28”一致;各子包通过 pnpm catalog: 引用 vitest(如 apps/studio/package.json),版本由 monorepo 根目录统一约束;
  • 钩子超时、sequence.hooks 等配置项的作用范围以各子包 vitest.config.ts 为准,apps/studio 额外启用了 globals: true(全局注入钩子,可省略 import)与 CI 条件重试,阅读该目录测试文件时需将这两点纳入时序分析;
  • e2e/studio 目录使用 Playwright 而非 Vitest,文中引用其代码仅为印证 fixture 三段式约定,其钩子 API 细节不在本文文档范围内。

参考起点:.agents/skills/vitest/references/core-hooks.md;同目录下的 core-describe.md(suite 组织)与 features-context.md(fixture 与 context)可与本文配合阅读,构成 Vitest 测试生命周期的完整知识链。

登录后查看全文
热门项目推荐
相关项目推荐