关于 studio-mock-api-tests
studio-mock-api-tests 是为 `apps/studio/` 组件测试提供的 MSW 实践指南,面向需要测试数据请求或变更的开发者。它说明如何在网络层拦截请求,并给出测试模板、类型约束和常见问题处理方式。
主要功能
- 01通过 MSW 拦截网络请求
- 02使用 customRender 挂载组件
- 03按 OpenAPI 类型构造响应
- 04覆盖查询与变更流程
- 05依据界面状态编写断言
适用场景
- 01测试依赖 API 查询的组件
- 02验证表单提交后的界面行为
- 03排查 MSW 请求匹配问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ supabase/ supabase --skill studio-mock-api-tests安装后在 Claude Code 中输入/studio-mock-api-tests确认已加载。
详细介绍
Studio MSW 组件测试
挂载 Studio 组件,使用 MSW 拦截其网络请求,并断言渲染内容和发送的数据。基础设施已经配置完毕——本技能提供可直接使用的模板和注意事项。
适用场景
- 组件(或它渲染的任何后代组件)调用了会请求
/platform/...、/v1/...或apps/studio/data/api.d.ts中其他端点的 React Query 钩子或变更函数。 - 你原本打算编写
vi.mock('@/data/some-query', ...)。不要这样做。 应该模拟网络请求,详见下文“为什么不使用 vi.mock”。
如果组件只是纯展示组件,不会获取数据,就不需要 MSW;直接渲染并断言即可。
模板
import { screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { describe, expect, test } from 'vitest'
import { MyComponent } from './MyComponent'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
type OrganizationResponse = components['schemas']['OrganizationResponse']
// Needed if the component renders inside a Sheet, Modal, Popover, or
// anything else built on Radix that uses Web Animations.
mockAnimationsApi()
describe('MyComponent', () => {
test('renders rows from the API', async () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<OrganizationResponse[]>([
{
/* ... */
},
]),
})
customRender(<MyComponent />)
expect(await screen.findByText('Acme')).toBeInTheDocument()
})
})
这就是完整的模式(根据交互需要添加 fireEvent、waitFor 或 userEvent——详见下文注意事项)。服务生命周期(listen/resetHandlers/
close)由 apps/studio/tests/vitestSetup.ts 处理——通过 addAPIMock 注册的处理程序仅对当前测试生效。
这些注意事项会耗掉你整个下午
1. 路径参数使用 :slug,而不是 {slug}
addAPIMock 根据 OpenAPI paths 进行类型检查,但路径参数会被转换为 MSW 的 :param 格式。自动补全会为你提供指引;如果类型检查报告路径不可赋值,说明你使用了 OpenAPI 的 {slug} 格式。
// ❌ TypeScript error, MSW won't match
path: '/platform/organizations/{slug}/projects'
// ✅ Correct
path: '/platform/organizations/:slug/projects'
2. 使用 HttpResponse.json,而不是 new HttpResponse
对于成功响应,始终使用 HttpResponse.json——即使是 204/201 无内容的端点。直接使用 new HttpResponse(null, { status: 201 }) 不会返回 content-type,openapi-fetch 可能因此卡住变更流程,导致 onSuccess 回调无声地失效。
// ❌ Mutation onSuccess silently never fires
response: () => new HttpResponse(null, { status: 201 })
// ✅ Works (pass the OpenAPI body shape explicitly — see gotcha #8)
response: () => HttpResponse.json<MyResponse>({}, { status: 201 })
3. Sheets/Modals 中的提交按钮需要使用 fireEvent.click
约定写法 <Button form={FORM_ID} type="submit" />(按钮位于表单之外,通过 id 关联),在 jsdom 中使用 userEvent.click 不一定能可靠地触发表单提交。请对提交按钮使用 fireEvent.click。如果仍然无法提交,请直接调用
fireEvent.submit(document.getElementById(FORM_ID)!)。使用 user.paste 而不是 user.type 填写输入框(详见 studio-testing 中的“让测试保持快速”)。
await user.click(screen.getByPlaceholderText('value'))
await user.paste('hello')
fireEvent.click(await screen.findByRole('button', { name: 'Save' }))
4. 受 Profile 限制的查询需要 profileContext
许多钩子(useOrganizationsQuery、data/projects/ 中的所有内容,以及调用 useProfile 的任何内容)都会等到 Profile 加载后才触发。请显式传入:
import type { ProfileContextType } from '@/lib/profile'
const PROFILE_CONTEXT: ProfileContextType = {
profile: {
id: 1,
auth0_id: 'auth0|test',
gotrue_id: 'gotrue-test',
username: 'testuser',
primary_email: '[email protected]',
first_name: null,
last_name: null,
mobile: null,
is_alpha_user: false,
is_sso_user: false,
disabled_features: [],
free_project_limit: null,
},
error: null,
isLoading: false,
isError: false,
isSuccess: true,
}
customRender(<MyComponent />, { profileContext: PROFILE_CONTEXT })
5. useParams 全局模拟为 { ref: 'default' }
项目作用域组件不需要模拟 Next 路由。只需在模拟路径中使用 'default' 作为项目 ref:
/v1/projects/default/secrets、/platform/projects/default/...。如果需要使用其他 ref,请通过 routerMock.setCurrentUrl(...) 覆盖(详见 apps/studio/tests/lib/route-mock.ts)。
6. 未处理的请求会明确报错——为渲染触发的每个端点添加模拟
全局设置了 mswServer.listen({ onUnhandledRequest: 'error' })。如果组件(或它渲染的任何子组件)发出了未模拟的请求,你会在 stderr 中看到 MSW 错误,并且很可能出现不稳定行为。卡片、列表和详情面板通常会触发嵌套查询(例如,OrganizationCard 会调用 useOrgProjectsInfiniteQuery)——请检查渲染子树中的行为并模拟所有请求,或者仅使用 vi.mock 替换嵌套组件。
7. 不要在处理程序的 path 中加入查询字符串
addAPIMock 接受通过 TrimQueryParams 提供的 ?foo=bar 后缀,但此辅助函数会在匹配前将其移除。MSW v2 无法通过路径字符串匹配查询参数——请在解析器中读取:
addAPIMock({
method: 'get',
path: '/platform/projects',
response: ({ request }) => {
const limit = new URL(request.url).searchParams.get('limit')
// ...
},
})
8. 始终为 HttpResponse.json 传递显式泛型
addAPIMock 的解析器针对 OpenAPI 成功响应体(以及标准的 { message: string } 错误封装,导出为 APIErrorBody)进行类型定义。但 MSW 的 HttpResponse.json 使用了 NoInfer,因此响应体类型不会根据上下文收窄。请显式传递预期形状——这也相当于一条自文档化的契约断言:
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
response: () => HttpResponse.json<OrganizationResponse[]>([...])
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Boom' }, { status: 500 })
如果 Mock 与契约不一致(封装错误、缺少字段、枚举值过时),现在会在编译时失败,而不是运行时。代价是每个解析器都要添加一条类型注解——非常值得。
对于网络边界上的 Mock,也优先使用 createMockOrganizationResponse(返回原始 OpenAPI OrganizationResponse),而不是 createMockOrganization(后者会扩展出前端派生的 managed_by / partner_id,这些字段由查询层附加)。同样的模式适用于任何扩展了 OpenAPI 架构的前端类型:创建一个返回原始 API 形状的 createMockXResponse 辅助函数。
9. 使用 mockPermissions 模拟 FGA 权限
检查权限的组件(useGrant、useAsyncCheckPermissionsV2)会请求 /platform/profile/permissions/v2。没有全局默认值——在每个需要权限的测试中模拟它:
import { mockPermissions, mockPermissionsError } from '@/tests/lib/msw-permissions'
import { allPermissionsFor, NO_PERMISSIONS } from '@/tests/lib/permissions-fixtures'
mockPermissions(allPermissionsFor(['my-org'])) // everything in my-org
mockPermissions(NO_PERMISSIONS) // denied everywhere
mockPermissions({ organizations: [{ slug: 'my-org', role: 'developer', permissions: [...], projects: [] }] })
mockPermissionsError({ status: 500 }) // grant resolves to an error
对于接收 grant 的 fetcher 或查询选项的单元测试,可以使用 @/tests/lib/test-grant 中的 testGrant(endpoint, { projectRef }),而不必经过权限检查。
优先断言 UI 状态
MSW 自己的最佳实践文档明确建议断言实际渲染的内容,而不是某个处理器是否被调用。“表单是否提交?”这个问题,最好通过 expect(onClose).toHaveBeenCalled() 或 findByText('Saved') 来回答,而不是通过监视解析器。
只有一种合理的例外:请求体本身就是你关心的契约,而服务器的响应没有将其原样返回。批量创建端点(例如 POST /v1/projects/:ref/secrets)就是典型情况——返回 201 且没有响应体,因此要验证发送的数据形状,唯一方法就是捕获它:
const requests: Array<{ ref: string | undefined; body: unknown }> = []
addAPIMock({
method: 'post',
path: '/v1/projects/:ref/secrets',
response: async ({ request, params }) => {
requests.push({ ref: params.ref as string | undefined, body: await request.json() })
return HttpResponse.json<CreateSecretsResponse>({}, { status: 201 })
},
})
// ... drive the UI ...
expect(requests).toEqual([{ ref: 'default', body: [{ name: 'API_KEY', value: 'new-value' }] }])
有疑问时,先断言 UI;只有当 UI 无法明显体现契约时,才捕获请求。
调试 MSW 测试
如果请求没有匹配上,请在测试文件顶部(或临时在 msw.ts 中)接入 MSW 的生命周期事件:
import { mswServer } from '@/tests/lib/msw'
mswServer.events.on('request:unhandled', ({ request }) => {
console.log('[MSW] UNHANDLED:', request.method, request.url)
})
mswServer.events.on('response:mocked', ({ request, response }) => {
console.log('[MSW] MATCHED:', request.method, request.url, response.status)
})
request:start 已在 msw.ts 中接入。测试出现异常时,可在本地添加 request:unhandled 和 response:mocked——这通常会暴露路径参数不匹配,或忘记模拟的嵌套查询。
为什么不用 vi.mock('@/data/...')
它会绕过网络边界,因此:
- 它会掩盖真实错误:查询键重命名或请求负载变更后,测试仍会通过,到了生产环境才会出问题。
- 它不会覆盖 React Query 的缓存、重试或失效流程——
onMutate、onSuccess和onError回调不会像实际运行时那样执行。(tkdodo.eu/blog/testing-react-query) - 它会独立于 OpenAPI 类型发生偏移——处理器会保持同步,模块级 Mock 则不会。
只有在处理非网络相关问题时才使用 vi.mock:例如想要替换的重量级子组件(如 Monaco 编辑器),或具有全局状态的 common 包钩子。
延伸阅读
- TkDodo — 测试 React Query — 介绍支撑本技能所有原则的权威参考。
- MSW 最佳实践:组织处理器 和 覆盖网络行为 — 基线处理器 + 每个测试中使用
server.use()的模式。 - MSW 最佳实践:避免断言请求 — 上文“优先断言 UI 状态”建议的出处。
代码库参考
| 内容 | 位置 |
|---|---|
| 仅查询示例(加载、错误、成功) | apps/studio/components/interfaces/Organization/OrgNotFound.test.tsx |
| 修改示例(表单、载荷断言) | apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.test.tsx |
通过 pg-meta 执行 SQL 的示例(基于 query 请求体的 POST 解析器分支) | apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx |
addAPIMock 源码 | apps/studio/tests/lib/msw.ts |
customRender 源码 | apps/studio/tests/lib/custom-render.tsx |
| 全局处理器与生命周期 | apps/studio/tests/lib/msw-global-api-mocks.ts, apps/studio/tests/vitestSetup.ts |
权限模拟与 testGrant | apps/studio/tests/lib/msw-permissions.ts, apps/studio/tests/lib/test-grant.ts |
| 相关技能 | studio-testing(何时编写组件测试)、studio-queries(钩子约定)、vitest |