关于 studio-testing
studio-testing 是面向 `apps/studio/` 的测试编写与组织指南,供开发者在新增功能、审查覆盖率或选择测试类型时参考。它强调将逻辑提取为纯函数,并按需采用单元、组件和端到端测试。
主要功能
- 01将组件逻辑提取为纯函数
- 02覆盖有效输入与边界情况
- 03按交互复杂度选择测试类型
- 04共享功能采用端到端测试
- 05使用假定时器缩短测试耗时
适用场景
- 01为 Studio 新功能设计测试
- 02审查测试覆盖是否充分
- 03重构组件逻辑以便单独测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ supabase/ supabase --skill studio-testing安装后在 Claude Code 中输入/studio-testing确认已加载。
详细介绍
Studio 测试策略
介绍如何为 apps/studio/ 编写和组织测试。核心原则:将逻辑从 React 组件中移到纯工具函数中,然后对这些函数进行全面测试。仅在复杂 UI 交互场景中使用组件测试。对自托管和平台共用的功能使用 E2E 测试。
适用场景
在以下情况下参考这些指南:
- 为 Studio 代码编写新测试
- 决定编写哪种类型的测试(单元测试、组件测试、E2E 测试)
- 从组件中提取逻辑,使其可测试
- 检查测试覆盖率是否足够
- 添加需要测试的新功能
按优先级划分规则类别
| 优先级 | 类别 | 影响 | 前缀 |
|---|---|---|---|
| 1 | 逻辑提取 | CRITICAL | testing- |
| 2 | 测试覆盖率 | CRITICAL | testing- |
| 3 | 组件测试 | HIGH | testing- |
| 4 | E2E 测试 | HIGH | testing- |
| 5 | 测试速度 | HIGH | testing- |
快速参考
1. 逻辑提取(CRITICAL)
testing-extract-logic- 将逻辑从组件中移出,放入.utils.ts文件,编写纯函数:输入参数,返回结果
2. 测试覆盖率(CRITICAL)
testing-exhaustive-permutations- 测试工具函数的所有排列:正常路径、格式错误的输入、空值、边界情况
3. 组件测试(HIGH)
testing-component-tests-ui-only- 仅为复杂 UI 交互逻辑编写组件测试,不要为业务逻辑编写
4. E2E 测试(HIGH)
testing-e2e-shared-features- 为自托管和平台都使用的功能编写 E2E 测试;覆盖点击操作和键盘快捷键
5. 测试速度(HIGH)
testing-node-environment- 纯逻辑测试放在.test.ts中(在 node 环境下运行);只有.test.tsx使用 jsdomtesting-fake-timers- 绝不等待真实时间;将虚拟计时器推进至超过防抖和轮询间隔testing-paste-not-type- 除非测试按键行为,否则使用user.paste或fireEvent.change设置输入值testing-scope-queries- 将*ByRole选项查询限定在已打开的列表框内testing-no-redundant-tests- 将几乎重复的测试合并到test.each中;删除不会失败或只覆盖库、类名或快照的测试
决策树:应选择哪种测试类型?
Is the logic a pure transformation (parse, format, validate, compute)?
YES -> Extract to .utils.ts, write unit test with vitest
NO -> Does the feature involve complex UI interactions?
YES -> Is it used in both self-hosted and platform?
YES -> Write E2E test in e2e/studio/features/
NO -> Write component test with customRender
NO -> Can you extract the logic to make it pure?
YES -> Do that, then unit test it
NO -> Write a component test
1. 将逻辑提取到工具文件中(CRITICAL)
尽可能多地将逻辑从组件中移出。将其放入同目录的 .utils.ts 文件中,编写纯函数:输入参数,返回结果。
文件命名:
- 工具文件:与组件放在一起,命名为
ComponentName.utils.ts - 测试文件:镜像源文件路径,命名为
tests/components/.../ComponentName.utils.test.ts
// ❌ Logic buried in component — hard to test without rendering
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
const handleSubmit = () => {
const taxId = TAX_IDS.find((t) => t.name === taxIdName)
let sanitized = taxIdValue
if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
sanitized = taxId.vatPrefix + taxIdValue
}
submitToApi(sanitized)
}
return <form onSubmit={handleSubmit}>...</form>
}
// ✅ Logic extracted to .utils.ts — trivially testable
// TaxID.utils.ts
export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
const taxId = TAX_IDS.find((t) => t.name === name)
if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
return taxId.vatPrefix + value
}
return value
}
// TaxIdForm.tsx — thin shell
const handleSubmit = () => {
const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
submitToApi(sanitized)
}
2. 测试所有排列(CRITICAL)
提取逻辑后,要进行全面测试。每条代码路径都需要测试:
- 有效输入(每个分支的正常路径)
- 无效或格式错误的输入
- 空值、null 值、缺失字段
- 边界情况(含冒号的时间戳、特殊字符、边界值)
// ❌ Only happy path
test('parses a filter', () => {
expect(formatFilterURLParams('id:gte:20')).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
})
// ✅ Every permutation
test('parses valid filter', () => { ... })
test('handles timestamp with colons in value', () => { ... })
test('rejects malformed filter with missing parts', () => { ... })
test('rejects unrecognized operator', () => { ... })
test('allows empty filter value', () => { ... })
如果多个用例的准备、执行和断言结构相同,请编写一个 test.each 表,而不要复制粘贴测试。每一行都是一种排列;表格能让遗漏一目了然。哪些内容不应测试,请参阅第 5 节。
3. 仅为复杂 UI 编写组件测试(HIGH)
仅当存在无法仅通过测试工具函数覆盖的复杂 UI 交互逻辑时,才编写组件测试。
合理原因: 用户交互序列触发的条件渲染、通过键盘/鼠标打开或关闭弹出框、多步骤表单状态转换。
不合理原因: 在组件中实现的计算或转换——应将其提取到 .utils.ts 并进行单元测试。
Studio 组件测试约定:
import { screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { HttpResponse } from 'msw'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
type OrganizationResponse = components['schemas']['OrganizationResponse']
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json<OrganizationResponse[]>([]),
})
customRender(<MyComponent />)
expect(await screen.findByText('No organizations')).toBeInTheDocument()
- 在网络层使用
addAPIMock(MSW)模拟 API 请求——未处理的请求会导致测试失败。不要使用vi.mock('@/data/...')。始终将 OpenAPI 正文类型传递给HttpResponse.json<…>。 customRender会将组件包装在 Studio 所需的提供程序中(React Query、路由器等)。- 完整模板、路径参数语法以及 jsdom/MSW 注意事项,请参阅
studio-mock-api-testsskill。
4. 为共享功能编写 E2E 测试(HIGH)
如果某项功能同时存在于自托管版和平台版,请创建 E2E 测试。 覆盖鼠标点击和键盘快捷键(Tab、Enter、Escape、方向键)。
将可复用的交互提取到 e2e/studio/utils/*-helpers.ts。使用
try/finally 清理资源。有关 E2E 执行详情,请参阅
studio-e2e-tests skill。
5. 保持测试快速(HIGH)
Studio 有数千个单元测试,每个文件多花几秒,每次 PR 累积起来就会增加几分钟。这些做法曾在分片之前将 CI 时间从约 14 分钟缩短到约 9 分钟。每个新测试都应遵循这些做法。
将纯逻辑放在 .test.ts 中,以便在 node 环境运行
vitest.config.ts 会在 node 环境中运行 **/*.test.ts,并在 jsdom 中运行 **/*.test.tsx。jsdom 每个文件的初始化成本约为 1 秒,因此:
- 工具函数、schema 和解析器测试:文件名使用
.test.ts,且不要导入 Testing Library 中的任何内容。 - 确实需要 DOM 的
.test.ts文件(例如renderHook、window.localStorage),可通过 docblock 启用,且 docblock 必须是首行:
// @vitest-environment jsdom
import { renderHook } from '@testing-library/react'
不要将纯逻辑测试改名为 .tsx,也不要“以防万一”添加 docblock。
DOM 设置文件(tests/setup/dom.ts、tests/setup/radix.js)在 node 环境中不会执行任何操作。
切勿等待实际时间流逝
不要休眠(setTimeout(resolve, …))、不要为了等待轮询间隔而增加 findBy* 超时时间,也不要增加单项测试的超时时间。使用假计时器并推进时间。shouldAdvanceTime: true 可确保 waitFor 轮询、MSW 和路由器微任务正常运行:
// ❌ Waits 4s+ of real time per poll (this file took 18s)
expect(
await screen.findByText('Restoration complete!', {}, { timeout: POLL_INTERVAL_MS + 2000 })
).toBeInTheDocument()
// ✅ Advance past the poll instead (same file now takes 0.3s)
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
const advancePoll = () => act(() => vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS))
await advancePoll()
expect(await screen.findByText('Restoration complete!')).toBeInTheDocument()
防抖也遵循相同做法:使用 act(() => vi.advanceTimersByTimeAsync(650)),不要休眠 650 毫秒。从源代码导出间隔/防抖常量,避免在测试中硬编码。
粘贴,不要逐字输入
user.type 会为每个字符触发 keydown/keypress/input/keyup,每次都可能重新渲染整个表单。除非你要测试按键级行为(输入掩码、快捷键、逐键验证),否则应一次性设置值:
// ❌ One re-render per character
await user.type(screen.getByPlaceholderText('Project name'), 'my-project')
// ✅ Focus then paste
await user.click(input)
await user.paste('my-project')
// ✅ Or a single change event
fireEvent.change(input, { target: { value: 'my-project' } })
如果确实需要使用 userEvent,请通过 userEvent.setup({ delay: null }) 创建实例,以取消每次操作的延迟。
缩小 *ByRole 查询范围
*ByRole 会对每个候选元素检查可访问性(包括 getComputedStyle)。Radix Select 会为每个选项渲染一个隐藏的原生 <option>,因此在大型表单上未限定范围的 findByRole('option') 会检查数百个元素。请将查询范围限定在已打开的列表框内:
// ❌ Scans every hidden <option> on the page
await user.click(await screen.findByRole('option', { name: 'East US' }))
// ✅ Only the open listbox
const findOption = async (name: string | RegExp) =>
within(await screen.findByRole('listbox')).findByRole('option', { name })
await user.click(await findOption('East US'))
对于非常大的页面,configure({ defaultHidden: true }) 会跳过逐个元素的可见性检查,使每次 *ByRole 查询的开销降低到约三分之一。仅当断言不依赖可见性时才使用。
不要编写冗余测试
每个测试都会占用 CI 时间。不要添加以下测试:
- 用不同的字面值重复另一个测试。请改用
test.each添加一行。 - 测试库而非我们的代码(例如,测试 zod 是否会拒绝错误类型,或 dayjs 是否会格式化日期)。
- 断言类名或快照。它们会因样式变更而失效,也无法捕获行为错误。
- 仅为提高覆盖率数字而存在。
确保每个测试都可能失败。以下是一些测试无论如何都会通过的常见原因:
- 异步断言未等待(
expect(promise).resolves…或未使用await的waitFor(…))。 - 测试数据与默认值相同,因此从未触发“已更改”的路径。
- 调用了错误的函数(例如,
semverGte测试实际调用了semverLte)。
快速检查:破坏被测代码,确认测试会失败。
代码库参考
| 内容 | 位置 |
|---|---|
| 工具函数测试示例 | apps/studio/tests/components/Grid/Grid.utils.test.ts、apps/studio/tests/components/Billing/TaxID.utils.test.ts、apps/studio/tests/components/Editor/SpreadsheetImport.utils.test.ts |
| 组件测试示例 | apps/studio/tests/features/logs/LogsFilterPopover.test.tsx、apps/studio/tests/components/CopyButton.test.tsx |
| Fake timers(轮询) | apps/studio/components/layouts/ProjectLayout/RestoringState.test.tsx |
| Fake timers(防抖) | apps/studio/hooks/misc/__tests__/useTableEditorFiltersSort.navigation.test.tsx |
| 作用域列表框查询 | apps/studio/tests/pages/new/[slug].test.tsx、apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx |
| E2E 测试示例 | e2e/studio/features/filter-bar.spec.ts |
| E2E 辅助函数模式 | e2e/studio/utils/filter-bar-helpers.ts |
| 自定义渲染 | apps/studio/tests/lib/custom-render.tsx |
| MSW 模拟设置 | apps/studio/tests/lib/msw.ts(addAPIMock) |
| 测试 README | apps/studio/tests/README.md |
| Vitest 配置 | apps/studio/vitest.config.ts |
| 相关技能 | studio-e2e-tests(运行 E2E)、vitest(API 参考)、vercel-composition-patterns(组件架构) |