mcpField
S
studio-testing

studio-testing

github.com/supabase测试与质量114.3k329 次安装今天更新
代码仓库

关于 studio-testing

studio-testing 是面向 `apps/studio/` 的测试编写与组织指南,供开发者在新增功能、审查覆盖率或选择测试类型时参考。它强调将逻辑提取为纯函数,并按需采用单元、组件和端到端测试。

主要功能

  1. 01将组件逻辑提取为纯函数
  2. 02覆盖有效输入与边界情况
  3. 03按交互复杂度选择测试类型
  4. 04共享功能采用端到端测试
  5. 05使用假定时器缩短测试耗时

适用场景

  1. 01为 Studio 新功能设计测试
  2. 02审查测试覆盖是否充分
  3. 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逻辑提取CRITICALtesting-
2测试覆盖率CRITICALtesting-
3组件测试HIGHtesting-
4E2E 测试HIGHtesting-
5测试速度HIGHtesting-

快速参考

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 使用 jsdom
  • testing-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-tests skill。

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)
测试 READMEapps/studio/tests/README.md
Vitest 配置apps/studio/vitest.config.ts
相关技能studio-e2e-tests(运行 E2E)、vitest(API 参考)、vercel-composition-patterns(组件架构)

评论

支持 Markdown