首页
/ Supabase Studio 测试策略:逻辑抽取、全排列测试与单测/组件/E2E 三层选型实践

Supabase Studio 测试策略:逻辑抽取、全排列测试与单测/组件/E2E 三层选型实践

2026-09-04 23:40:54作者:幸俭卉

本文基于 Supabase 仓库中的 studio-testing 技能文档,系统讲解 apps/studio/ 应用的测试编写策略:如何把业务逻辑从 React 组件中抽离为可单测的纯函数、如何对每个代码路径做全排列测试、何时才值得写组件测试与 Playwright E2E 测试。读完本文,你可以直接按仓库既定的文件命名、目录镜像和 mock 约定为新功能补测试,并理解 customRenderaddAPIMock 等测试基础设施的底层实现。

1. 核心原则与适用场景

策略文档开篇给出了 Studio 测试的总纲(引自 SKILL.md):

核心原则:把逻辑从 React 组件中抽离成纯工具函数,然后对这些函数做穷尽式测试。只有复杂 UI 交互才使用组件测试。E2E 测试用于 self-hosted 和 platform 两种形态共有的功能。

以下场景应遵循这套指南:

  • 为 Studio 代码编写新测试;
  • 决定写哪种类型的测试(单测、组件测试、E2E);
  • 从组件中抽取逻辑以提升可测性;
  • 评审测试覆盖率是否充分;
  • 为需要测试的新功能立项。

2. 规则类别与优先级

策略把测试规则按优先级分为四级,全部使用 testing- 前缀标记:

优先级 类别 影响级别 前缀
1 Logic Extraction(逻辑抽取) CRITICAL testing-
2 Test Coverage(测试覆盖) CRITICAL testing-
3 Component Tests(组件测试) HIGH testing-
4 E2E Tests(端到端测试) HIGH testing-

对应的快速参考规则:

  1. testing-extract-logic — 把逻辑从组件中移出,放入 .utils.ts 文件,写成纯函数:参数进,返回值出;
  2. testing-exhaustive-permutations — 对工具函数的每一种排列组合做测试:正常路径、畸形输入、空值、边界情况;
  3. testing-component-tests-ui-only — 只为复杂 UI 交互逻辑写组件测试,不为业务逻辑写;
  4. testing-e2e-shared-features — 为 self-hosted 和 platform 共用的功能写 E2E 测试;同时覆盖鼠标点击和键盘快捷键。

3. 决策树:该写哪种测试?

文档给出了一棵可直接套用的决策树,先问"是否纯转换",再问"是否是复杂 UI 交互",最后才落到组件测试:

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

按这棵树,绝大多数数据解析/格式化/校验逻辑都应止步于第一分支(单测);E2E 是最后的重量级手段,只投给双形态共享的功能。

4. 规则一(最重要):把逻辑抽进 .utils.ts

要求把组件中能移出的逻辑尽可能移出,放入与组件同目录.utils.ts 文件,写成纯函数——参数进,返回值出。

文件命名约定:

  • 工具函数:ComponentName.utils.ts,与组件文件相邻;
  • 测试文件:tests/components/.../ComponentName.utils.test.ts,路径镜像源码路径,便于查找。

反例与正例(摘自策略文档):

// ❌ 逻辑埋在组件里——不渲染就无法测试
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>
}

// ✅ 逻辑抽到 .utils.ts——极易测试
// 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 —— 只剩薄壳
const handleSubmit = () => {
  const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
  submitToApi(sanitized)
}

仓库中的真实印证: TaxID.utils.test.ts 正是该模式的标准产物。它测试从 TaxID.utils 导入的 sanitizeTaxIdValuegetEffectiveTaxCountryresolveStoredTaxId 三个纯函数,文件头部注释直接说明了为什么要抽出来测试——Stripe 期望带国家前缀的税号(ATU12345678),用户可能只输入数字(12345678),函数需覆盖三种情况:补前缀、已带前缀透传、非欧盟税号透传:

/**
 * We're sanitizing EU tax ids. Stripe expects a prefixed tax id (ATU12345678),
 * but users might not realize this and enter only the numbers (12345678)...
 * 1. take an un-prefixed tax id (12345678) and add the country prefix to it (ATU12345678)
 * 2. take a correctly prefixed tax id (ATU12345678) and just pass it through
 * 3. take a non-EU tax id and just pass it through
 */
describe('TaxID utils: sanitizeTaxID', () => {
  test('should prefix an EU tax ID correctly', () => {
    const austriaTaxID = { id: 'txi_...', type: 'eu_vat', value: '12345678', name: 'AT VAT' }
    const sanitizedID = sanitizeTaxIdValue(austriaTaxID)
    expect(sanitizedID).toBe('ATU12345678')
  })
  // ... 另外两个用例分别覆盖 "已带前缀" 与 "非 EU 透传"
})

该文件同时测试了 getEffectiveTaxCountry(无覆盖时返回 countryIso2,有覆盖时返回 taxCountryIso2,例如 IM VAT 映射到 GB)和 resolveStoredTaxId(含未知类型返回 undefined 的分支),完整演示了"每个分支都有用例"的写法。

5. 规则二(最重要):测试每一种排列组合

逻辑抽出后要穷尽测试,每个代码路径都需要用例,至少覆盖四类输入:

  • 合法输入(每条分支的正常路径);
  • 非法/畸形输入;
  • 空值、null、缺失字段;
  • 边界情况(带冒号的时间戳、特殊字符、边界值)。

文档用过滤器参数解析对比了"只测 happy path"与"全排列"两种写法:

// ❌ 只测了正常路径
test('parses a filter', () => {
  expect(formatFilterURLParams('id:gte:20')).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
})

// ✅ 每一种排列组合
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', () => { ... })

仓库中的真实印证: Grid.utils.test.ts 测试的是 SupabaseGrid.utils 中从 URL 参数解析排序/过滤的纯函数 formatSortURLParamsformatFilterURLParams。URL 语法为 column:order(排序)与 column:operatorAbbreviation:value(过滤)。除了正常解析用例,它显式测试了畸形输入的容错:

// Sort URL syntax: `column:order`
test('should reject any malformed sort options based on URL params', () => {
  const mockInput = ['id', 'name:asc', ':asc']
  const output = formatSortURLParams(
    { name: 'fakeTable', columns: [{ name: 'id' }, { name: 'name' }] },
    mockInput
  )
  expect(output).toStrictEqual([
    { table: 'fakeTable', column: 'name', ascending: true },
  ])
})

test('should reject any sort options with non-existent columns based on URL params', () => {
  const mockInput = ['name2:asc']
  const output = formatSortURLParams(
    { name: 'fakeTable', columns: [{ name: 'name' }] },
    mockInput
  )
  expect(output).toStrictEqual([])
})

注意一个容易被忽略的细节:该文件还演示了如何用 vi.hoisted + vi.mock('sonner') 把 toast 副作用 mock 掉——纯函数一旦涉及副作用依赖,这是保持"参数进、返回值出"测试风格的标准手法。

运行环境: 这些单测由 vitest.config.ts 驱动,关键配置包括:

  • environment: 'jsdom'globals: true,React 组件用 @vitejs/plugin-react 编译;
  • retry: IS_CI ? 2 : 0 —— 仅在 CI 中为 flaky 测试重试 2 次,本地失败立即暴露;
  • setupFiles 加载三个初始化文件: polyfills.tsvitestSetup.ts、radix 补丁;
  • coverage 只统计 lib/**/*.ts,排除测试文件本身。

6. 规则三:只为复杂 UI 交互写组件测试

合理理由: 用户交互序列驱动的 conditional rendering、键盘/鼠标操作的 popover 开合、多步表单跳转。 不合理理由: 测试某个碰巧写在组件里的计算或转换——应抽到 .utils.ts 做单测。

组件测试必须遵循的导入约定(引自策略文档):

// Studio 组件测试约定
import { fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { customRender } from 'tests/lib/custom-render' // 始终用 customRender,而不是裸 render
import { addAPIMock } from 'tests/lib/msw' // 在 beforeEach 中做 API mock

customRender 的底层实现: custom-render.tsx 是对 render/renderHook 的包装,通过 CustomWrapper 注入四个必需 Provider:QueryClientProvider(默认 retry: false 保证测试确定性)、NuqsTestingAdapter(URL 查询参数测试适配)、TooltipProviderCommandProvider。它还支持可选的 queryClientnuqsprofileContext 三个参数,其中 nuqs 允许为使用 Nuqs 的组件预置 URL 查询参数:

customRender(<MyComponent />, {
  nuqs: {
    searchParams: { search: 'hello world' },
  },
})

API mock 的底层实现: msw.ts 基于 MSW 的 setupServer 构建,其核心 addAPIMock 是强类型的——它从 OpenAPI 生成的 paths 类型(见 apps/studio/data/api)中提取 200/201 响应体,把 {param} 风格路径重映射为 MSW 的 :param 风格,从而在编译期捕捉 mock 与 API 契约之间的漂移:

export const addAPIMock = <P extends Endpoints | `${Endpoints}?${string}`, M extends Methods>({
  method,
  path,
  response,
}: { ... }) => {
  const fullPath = `${API_URL}${path}`
  mswServer.use(
    httpmethod => HttpResponse.json(response ?? null))
  )
}

配套的 tests/README.md 补充了使用纪律:

  • mock 必须放在 beforeEach 中,多个测试共存时 beforeAll 不生效;
  • mock 数据与被使用的测试放在同一目录,并加一条"mock 工作正常"的自检用例以便调试:
test('mock is working', async () => {
  const response = await fetch('/api/my-endpoint')
  expect(response.json()).resolves.toEqual({ data: { foo: 'bar' } })
})
  • 模拟 <Popover>userEvent.click,模拟 <Dropdown>tests/helpers 中的 clickDropdown,二者交互方式不同,混用会导致定位失败;
  • 测试目录按功能分组(如 /logs/reports/projects),避免用易变的文件/目录名命名。

仓库中的组件测试范例: CopyButton.test.tsx 展示了"复杂 UI 交互"的轻量形态——断言点击后文案从 Copy 变为 Copied、回调被调用,以及 primary 变体不显示绿色复制图标(通过 class 断言 text-inherit vs text-brand);更复杂的交互序列测试见 LogsFilterPopover.test.tsx(popover 开合与键盘操作)。

7. 规则四:为共享功能写 E2E 测试

判据是功能是否同时存在于 self-hosted 与 platform 两种形态;若是,在 e2e/studio/features/ 下创建 Playwright 测试,且必须同时覆盖鼠标点击和键盘快捷键(Tab、Enter、Escape、方向键)。

仓库中的标杆范例: filter-bar.spec.ts 覆盖了 Studio 表编辑器过滤栏,组织为多个 test.describe 块:

  • Basic Filter Operations —— 点选列生成过滤条件、无值时不触发行请求(防抖回归)、输入值过滤网格、X 按钮删除条件、多条件叠加;
  • Keyboard Navigation - Freeform Input / Operator / Value —— Enter 从下拉选择列、Tab 退出过滤栏、ArrowDown/ArrowUp 遍历下拉项、空输入时 Backspace 高亮并删除上一条条件、ArrowLeft/ArrowRight 在高亮条件间移动、Escape 清除高亮、Shift+Tab 反向退出等;
  • Boolean / Date / Timestamp / IS NULL Filters —— 各列类型的过滤语义,日期用例特意用 JS 本地时间计算插入值以规避时区 flakiness(源码注释说明了为何不用 CURRENT_DATE);
  • Filter Error Feedback —— 非法值的友好错误提示。

每个用例都严格遵循 try/finally 清理模式:用 createTable 建测试表、断言、finallydropTable,避免测试间状态污染:

test('entering value filters the grid', async ({ page, ref }) => {
  const tableName = `${tableNamePrefix}_val_filt`
  await createTable(tableName, columnName, [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }])

  try {
    await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`))
    await navigateToTable(page, ref, tableName)

    await addFilter(page, ref, columnName, '=', 'Alice')

    await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible()
    await expect(page.getByRole('gridcell', { name: 'Bob' })).not.toBeVisible()
    await expect(page.getByRole('gridcell', { name: 'Charlie' })).not.toBeVisible()
  } finally {
    await dropTable(tableName)
  }
})

可复用交互抽取为 helper: 文档要求把可复用交互抽到 e2e/studio/utils/*-helpers.tsfilter-bar-helpers.ts 正是该模式的样板:它封装了 getFilterBarInputnavigateToTable(点击表名并用 createApiResponseWaiter 等待 pg-meta 的 table-rows 响应)、selectColumnFilteraddFilter 等组合动作,spec 文件本身因此只写"场景 + 断言",不再重复定位逻辑。

E2E 的运行方式(摘自关联技能 studio-e2e-tests):

# 必须从 e2e/studio 目录运行
cd e2e/studio && pnpm run e2e

# 只跑单个文件
cd e2e/studio && pnpm run e2e -- features/filter-bar.spec.ts

# 按名称过滤
cd e2e/studio && pnpm run e2e -- --grep "test name pattern"

# UI 模式调试
cd e2e/studio && pnpm run e2e -- --ui

该技能说明测试会通过 web server 配置自动拉起 Supabase 本地容器,self-hosted 模式(IS_PLATFORM=false)下以 3 个 worker 并行执行;统一从 e2e/studio/utils/test.ts 导入自定义 test 对象,fixture 会注入 pageref 等。

8. 代码库参照索引

策略文档末尾给出了一个"想查什么去哪里"的索引表,以下路径均已相对仓库根目录:

内容 位置
工具函数测试示例 Grid.utils.test.tsTaxID.utils.test.tsSpreadsheetImport.utils.test.ts
组件测试示例 LogsFilterPopover.test.tsxCopyButton.test.tsx
E2E 测试示例 filter-bar.spec.ts
E2E helper 模式 filter-bar-helpers.ts
Custom render custom-render.tsx
MSW mock 设置 msw.ts(addAPIMock)
测试 README tests/README.md
Vitest 配置 vitest.config.ts
关联技能 studio-e2e-tests(运行 E2E)、vitest(API 参考)、vercel-composition-patterns(组件架构)

9. 小结:一条可执行的落地清单

结合策略文档与上述仓库证据,为 Studio 新功能补测试时可以按顺序执行:

  1. 先抽逻辑:凡是 parse/format/validate/compute,先落到 ComponentName.utils.ts 纯函数,测试文件镜像到 tests/.../ComponentName.utils.test.ts;
  2. 再穷尽排列:每个分支至少一条用例,畸形输入与空值必须单独成例,可参照 Grid.utils.test.ts 的"reject malformed"用例命名习惯;
  3. 慎写组件测试:仅当交互序列本身(开合、焦点流转、多步表单)需要断言时,用 customRender + beforeEach 中的 addAPIMock,不要渲染整个组件去测一行计算;
  4. E2E 投给共享功能:self-hosted 与 platform 共用的功能才进 e2e/studio/features/,点击与键盘快捷键成对覆盖,交互抽成 *-helpers.ts,资源创建一律 try/finally 清理;
  5. CI 语义要懂:retry 只在 CI 生效、mock 必须进 beforeEach、mock 自检用例要随 mock 一起提交——这些细节能显著降低"偶尔失败"的排查成本。

这套分层策略的本质,是让 90% 的回归保障落在最便宜、最稳定的纯函数单测层,把昂贵的组件测试与 E2E 留给真正需要真实渲染与真实浏览器的交互行为——这也是 filter-bar.spec.ts 能写出近 1200 行、覆盖二十余种键盘组合而不显臃肿的原因:它测的全是"只有真浏览器才能证明"的事情。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384