Supabase Studio 测试策略:逻辑抽取、全排列测试与单测/组件/E2E 三层选型实践
本文基于 Supabase 仓库中的 studio-testing 技能文档,系统讲解 apps/studio/ 应用的测试编写策略:如何把业务逻辑从 React 组件中抽离为可单测的纯函数、如何对每个代码路径做全排列测试、何时才值得写组件测试与 Playwright E2E 测试。读完本文,你可以直接按仓库既定的文件命名、目录镜像和 mock 约定为新功能补测试,并理解 customRender、addAPIMock 等测试基础设施的底层实现。
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- |
对应的快速参考规则:
testing-extract-logic— 把逻辑从组件中移出,放入.utils.ts文件,写成纯函数:参数进,返回值出;testing-exhaustive-permutations— 对工具函数的每一种排列组合做测试:正常路径、畸形输入、空值、边界情况;testing-component-tests-ui-only— 只为复杂 UI 交互逻辑写组件测试,不为业务逻辑写;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 导入的 sanitizeTaxIdValue、getEffectiveTaxCountry、resolveStoredTaxId 三个纯函数,文件头部注释直接说明了为什么要抽出来测试——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 参数解析排序/过滤的纯函数 formatSortURLParams、formatFilterURLParams。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.ts、vitestSetup.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 查询参数测试适配)、TooltipProvider 与 CommandProvider。它还支持可选的 queryClient、nuqs、profileContext 三个参数,其中 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 建测试表、断言、finally 中 dropTable,避免测试间状态污染:
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.ts。filter-bar-helpers.ts 正是该模式的样板:它封装了 getFilterBarInput、navigateToTable(点击表名并用 createApiResponseWaiter 等待 pg-meta 的 table-rows 响应)、selectColumnFilter、addFilter 等组合动作,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 会注入 page、ref 等。
8. 代码库参照索引
策略文档末尾给出了一个"想查什么去哪里"的索引表,以下路径均已相对仓库根目录:
| 内容 | 位置 |
|---|---|
| 工具函数测试示例 | Grid.utils.test.ts、TaxID.utils.test.ts、SpreadsheetImport.utils.test.ts |
| 组件测试示例 | LogsFilterPopover.test.tsx、CopyButton.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 新功能补测试时可以按顺序执行:
- 先抽逻辑:凡是 parse/format/validate/compute,先落到
ComponentName.utils.ts纯函数,测试文件镜像到tests/.../ComponentName.utils.test.ts; - 再穷尽排列:每个分支至少一条用例,畸形输入与空值必须单独成例,可参照 Grid.utils.test.ts 的"reject malformed"用例命名习惯;
- 慎写组件测试:仅当交互序列本身(开合、焦点流转、多步表单)需要断言时,用
customRender+beforeEach中的addAPIMock,不要渲染整个组件去测一行计算; - E2E 投给共享功能:self-hosted 与 platform 共用的功能才进 e2e/studio/features/,点击与键盘快捷键成对覆盖,交互抽成
*-helpers.ts,资源创建一律try/finally清理; - CI 语义要懂:
retry只在 CI 生效、mock 必须进beforeEach、mock 自检用例要随 mock 一起提交——这些细节能显著降低"偶尔失败"的排查成本。
这套分层策略的本质,是让 90% 的回归保障落在最便宜、最稳定的纯函数单测层,把昂贵的组件测试与 E2E 留给真正需要真实渲染与真实浏览器的交互行为——这也是 filter-bar.spec.ts 能写出近 1200 行、覆盖二十余种键盘组合而不显臃肿的原因:它测的全是"只有真浏览器才能证明"的事情。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00