首页
/ Supabase 仓库实战:Vitest Test API 深度解析——从 test/it 定义到参数化与上下文

Supabase 仓库实战:Vitest Test API 深度解析——从 test/it 定义到参数化与上下文

2026-09-06 12:00:40作者:俞予舒Fleming

本文基于 Supabase 仓库中的 Vitest 参考资料 core-test-api.md,系统讲解 test/it 测试定义函数及其全套修饰符、参数化与测试上下文机制。读完本篇,你将掌握在 Vitest 4.x 下编写同步/异步测试、条件跳过、参数化、自定义 fixture 与重试配置的方法,并能对照 Supabase 仓库中 apps/studio/vitest.config.tspackages/pg-meta/test/columns.test.ts 等真实测试文件验证每项能力的实际用法。

仓库中的 Vitest 环境基线

在展开 API 之前,先确认 Supabase 仓库的实际测试环境,作为本文所有示例的适用前提:

  • 版本:monorepo 通过 pnpm catalog 统一管理,pnpm-workspace.yaml 中声明 vitest: ^4.1.4,同时引入 @vitest/coverage-v8@vitest/ui。因此本文示例面向 Vitest 4.x,test.for 等较新能力均可用。
  • 各应用通过 catalog: 复用同一版本,例如 apps/studio/package.json 中的 testtest:watchtest:uitest:ci 脚本,以及 packages/ui/package.json 中的 test 脚本。
  • 一个值得注意的真实配置:apps/studio/vitest.config.ts 中设置了 globals: true(全局注入 test/expect)、environment: 'jsdom',并且 retry: IS_CI ? 2 : 0——注释明确说明“仅在 CI 中重试 flaky 测试,本地失败应立刻暴露”。这正是本文后文“Retry Configuration”一节在大型仓库中的落地范式。

基本测试:test 与 it 别名

最小测试单元从 vitest 导入 test(或别名 it):

import { expect, test } from 'vitest'

test('adds numbers', () => {
  expect(1 + 1).toBe(2)
})

// Alias: it
import { it } from 'vitest'

it('works the same', () => {
  expect(true).toBe(true)
})

两者完全等价,选择哪个只是团队风格问题。Supabase 仓库两种写法并存:例如 packages/ai-commands/src/sql/functions.test.ts 显式 import { describe, expect, test } from 'vitest',而 apps/studio/vitest.config.ts 开启 globals: true 后,studio 应用的大量测试文件(如 apps/studio/tests/features/reports/storage-report.test.tsx)则直接使用全局注入的 test

一个容易忽视的细节:如果传入的第一个参数是函数,函数名会作为测试名——这在用工厂函数批量生成测试时很有用。

异步测试

test('async test', async () => {
  const result = await fetchData()
  expect(result).toBeDefined()
})

// Promises are automatically awaited
test('returns promise', () => {
  return fetchData().then(result => {
    expect(result).toBeDefined()
  })
})

关键点:

  • 测试函数声明为 async 时,内部 await 的完成时间计入超时;
  • 同步函数直接 return 一个 Promise 时,Vitest 会自动 await,测试在该 Promise 结算前不会结束;
  • 未捕获的 rejection 会直接使测试失败。

Supabase 仓库中异步测试的典型形态是 packages/pg-meta/test/columns.test.ts:先 await createTestDatabase() 建立真实数据库连接,再在 afterAllawait db.cleanup(),测试主体全部围绕数据库往返操作展开——这要求异步语义正确,否则连接泄漏会拖垮整个套件。

测试选项:超时与选项对象

// Timeout (default: 5000ms)
test('slow test', async () => {
  // ...
}, 10_000)

// Or with options object
test('with options', { timeout: 10_000, retry: 2 }, async () => {
  // ...
})
  • 第三参数直接传数字时等价于 { timeout: 10_000 },默认超时 5000ms;
  • 选项对象还可包含 retrytags 等(后文详述)。

需要区分的是单测试级别配置级别的超时/重试:仓库配置里的 retry: IS_CI ? 2 : 0apps/studio/vitest.config.ts)作用于全部测试,而选项对象中的 retry: 2 只作用于单个测试。对连接真实 Postgres 的集成测试(如 pg-meta 测试套件)来说,理解这两层作用域的优先级非常重要。

测试修饰符

跳过测试(skip / skipIf / runIf)

test.skip('skipped test', () => {
  // Won't run
})

// Conditional skip
test.skipIf(process.env.CI)('not in CI', () => {})
test.runIf(process.env.CI)('only in CI', () => {})

// Dynamic skip via context
test('dynamic skip', ({ skip }) => {
  skip(someCondition, 'reason')
  // ...
})

三种粒度对应不同场景:test.skip 是静态跳过;skipIf/runIf定义时根据条件(如 process.env.CI)决定去留;skip(condition, reason)运行时通过上下文动态跳过,且能给出原因,适合依赖外部资源是否可用的场景。

Supabase 仓库中大量使用了静态 test.skip 来“冻结”尚未稳定的集成测试,例如 apps/studio/tests/features/logs/logs-query.test.tsx 中连续多个 test.skip('can display log data', ...) 以及其中嵌套 describe 内的 test.skipapps/studio/tests/features/reports/storage-report.test.tsx 同样如此——这些被跳过的用例保留了断言意图,待后端就绪后可直接恢复,是大型仓库中管理技术债的常见手法。

聚焦测试(only)

test.only('only this runs', () => {
  // Other tests in file are skipped
})

test.only 会让同文件内其他测试被跳过。仓库文档给出的关键约束:在 CI 中 test.only 会直接抛错,除非显式配置 allowOnly: true——这是防止调试用 only 被误提交、导致 CI 静默少跑测试的重要安全网。

待办测试(todo)

test.todo('implement later')

test.todo('with body', () => {
  // Not run, shows in report
})

todo 测试不执行,但会出现在测试报告中,用于记录“已认领未实现”的用例,便于跟踪测试覆盖率缺口。

期望失败的测试(fails)

test.fails('expected to fail', () => {
  expect(1).toBe(2) // Test passes because assertion fails
})

test.fails 反转结果:断言失败反而算通过。适合“锁定当前错误行为,待修复后翻转”的场景——注意它与 test.skip 的差别:fails 会真正执行并校验失败形态,一旦行为被修复,该测试会失败提醒你同步更新。

并发测试(concurrent)

// Run tests in parallel
test.concurrent('test 1', async ({ expect }) => {
  // Use context.expect for concurrent tests
  expect(await fetch1()).toBe('result')
})

test.concurrent('test 2', async ({ expect }) => {
  expect(await fetch2()).toBe('result')
})

默认情况下同一文件内测试串行执行;test.concurrent 让测试真正并行,适合彼此无共享可变状态的 I/O 密集用例。仓库中的实例是 packages/ai-commands/src/sql/functions.test.ts:两个 test.concurrent 用例各自调用 debugSql(OpenAI 网络请求),用 expect(await ...) 处理异步断言,这正是文档强调的并发测试必须使用上下文 expect(因为 expect.not.toMatchSnapshot() 等快照断言在并发下不安全,而上下文绑定的 expect 与具体测试任务关联)。

顺序测试(sequential)

// Force sequential in concurrent context
test.sequential('must run alone', async () => {})

在整体并发策略下,test.sequential 把单个测试强制拉回串行执行,用于保护依赖共享状态或必须独占资源的用例。

参数化测试

test.each

test.each([
  [1, 1, 2],
  [1, 2, 3],
  [2, 1, 3],
])('add(%i, %i) = %i', (a, b, expected) => {
  expect(a + b).toBe(expected)
})

// With objects
test.each([
  { a: 1, b: 1, expected: 2 },
  { a: 1, b: 2, expected: 3 },
])('add($a, $b) = $expected', ({ a, b, expected }) => {
  expect(a + b).toBe(expected)
})

// Template literal
test.each`
  a    | b    | expected
  ${1} | ${1} | ${2}
  ${1} | ${2} | ${3}
`('add($a, $b) = $expected', ({ a, b, expected }) => {
  expect(a + b).toBe(expected)
})

三种数据形态:

  1. 二维数组:内层数组被展开放行参数,%i/%s 等占位符格式化标题;
  2. 对象数组$propName 从对象属性取值;
  3. 模板字符串:表格化书写,可读性最佳,适合参数多的场景。

Supabase 仓库中 test.each 是数据驱动测试的主力。例如 apps/studio/lib/ai/tools/tool-sanitizer.test.tstest.each 枚举三类“malformed notebook output”,统一验证 sanitizeMessagePart 的 fail-closed 行为;apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.test.tstest.each(['localhost', '127.0.0.1', ...]) 覆盖全部本地 host 形态。describe.each 同理,见 apps/studio/tests/features/logs/logs-query.test.tsxdescribe.each(['free', 'pro', 'team', 'enterprise'])('upgrade modal for %s', ...) 按订阅计划展开同一组 UI 断言。

test.for

test.for([
  [1, 1, 2],
  [1, 2, 3],
])('add(%i, %i) = %i', ([a, b, expected], { expect }) => {
  // Second arg is TestContext
  expect(a + b).toBe(expected)
})

test.for 是 Vitest 4 引入的更优替代:不展平数组,每行数据作为单个数组参数传入(配合 [a, b, expected] 解构),同时第二个参数是完整的 TestContext,因此可以安全使用上下文 expecttask 元数据。仓库中最典型的例子是 packages/pg-meta/test/columns.test.ts

test.concurrent.for([
  { type: 'int2', etype: '_int2' },
  { type: 'int4', etype: '_int4' },
  // ... 覆盖数值、json、文本、日期时间、uuid/bool/bytea 等全部类型
])('$type[] -> $etype', async (c, { expect, task }) => {
  const id = { schema: 'public', table: 't', name: `c${task.id}` }
  // 每个用例创建独立列,避免并发写共享表时互相干扰
  const { sql } = pgMeta.columns.create({

这里能看出两个细节:task.id 被用来生成唯一的列名 c${task.id},这是并发参数化测试避免资源冲突的实用技巧;$type/$etype 从对象属性插值生成清晰的用例标题。同文件 第 481 行 还有第二组 test.concurrent.for

测试上下文(TestContext)

test('with context', ({ expect, skip, task }) => {
  console.log(task.name)   // Test name
  skip(someCondition)     // Skip dynamically
  expect(1).toBe(1)       // Context-bound expect
})

测试函数第一个参数即 TestContext,提供:

  • expect:与当前测试任务绑定的断言器,文档明确要求并发测试和快照断言一律使用上下文的 expect,避免跨测试的快照状态串扰;
  • skip(condition, reason?):运行时动态跳过;
  • task:当前任务元数据(task.nametask.id 等),用于日志、命名资源、调试。

前文 packages/pg-meta/test/columns.test.ts{ expect, task } 解构就是标准用法;packages/ai-commands/src/sql/functions.test.tsasync ({ expect }) 则展示了并发场景只取 expect 的最小用法。

基于 Fixture 的自定义测试

import { test as base } from 'vitest'

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

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

test.extend 允许把带生命周期的资源注入为 fixture:use 调用前是 setup(创建资源),use 之后是 teardown(清理资源),测试函数通过解构 { db } 获得实例。对 Supabase 这类围绕 Postgres 生态的仓库来说,这种模式天然契合“每个测试拿到干净数据库、结束后自动回收”的集成测试诉求。从当前仓库源码结构看,pg-meta 测试采用的是更底层的 createTestDatabase() + afterAll 手动清理写法(packages/pg-meta/test/columns.test.ts),而非 base.extend,但二者目标一致,extend 只是把这套 setup/teardown 抽象成可复用、可依赖的其他 fixture。

重试配置(Retry)

test('flaky test', { retry: 3 }, async () => {
  // Retries up to 3 times on failure
})

// Advanced retry options
test('with delay', {
  retry: {
    count: 3,
    delay: 1000,
    condition: /timeout/i, // Only retry on timeout errors
  },
}, async () => {})

retry 支持两种形式:

  • 数字简写{ retry: 3 },失败后最多重试 3 次;
  • 对象形式count(次数)、delay(每次重试间隔毫秒数)、condition(仅当错误匹配正则时才重试,如只重试超时类错误)。

对照 apps/studio/vitest.config.ts 的做法——retry: IS_CI ? 2 : 0 且注释写明“failures locally should surface immediately”——可以看出最佳实践是:全局默认不重试让问题尽快暴露,仅在 CI 的并行网络/定时器抖动环境下给予有限重试;对确实 flaky 的个别测试再用选项对象定点重试,而不是全局放大重试次数掩盖真实故障。

标签(Tags)

test('database test', { tags: ['db', 'slow'] }, async () => {})

// Run with: vitest --tags db

标签是测试的组织维度,运行时用 vitest --tags <name> 筛选。适合表达“慢”“需要真实数据库”“仅 e2e 前置”等横切属性,比用文件名或目录硬切分更灵活。

关键要点速查

结合上述全部内容,把仓库文档的 Key Points 汇总成一张速查表:

要点 说明
无 body 的 test('name') 自动标记为 todo
test.only 在 CI 默认抛错,需 allowOnly: true 才允许——防止调试残留静默缩减 CI 覆盖面
并发测试与快照 必须使用上下文(TestContext)的 expect
函数作第一参数 函数名自动成为测试名
参数化首选 test.for 不展平数组且自带 TestContext,优于 test.each

在 Supabase 仓库中的落地验证

本篇所有 API 在仓库中均有可运行的实证,建议按以下路径继续阅读:

掌握本文内容后,你可以在 Vitest 4 环境下独立完成:从最简 test/it 起步,按场景选择 skip/only/todo/fails/concurrent 修饰符,用 test.for 做参数化,用 TestContext 处理并发安全断言与动态跳过,用 base.extend 注入带清理逻辑的 fixture,并按“本地零重试、CI 有限重试”的原则配置 retry——这正是 Supabase 仓库 monorepo 测试体系背后的完整方法论。

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