mcpField
S
studio-mock-api-tests

studio-mock-api-tests

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

关于 studio-mock-api-tests

studio-mock-api-tests 是为 `apps/studio/` 组件测试提供的 MSW 实践指南,面向需要测试数据请求或变更的开发者。它说明如何在网络层拦截请求,并给出测试模板、类型约束和常见问题处理方式。

主要功能

  1. 01通过 MSW 拦截网络请求
  2. 02使用 customRender 挂载组件
  3. 03按 OpenAPI 类型构造响应
  4. 04覆盖查询与变更流程
  5. 05依据界面状态编写断言

适用场景

  1. 01测试依赖 API 查询的组件
  2. 02验证表单提交后的界面行为
  3. 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 包钩子。

延伸阅读

代码库参考

内容位置
仅查询示例(加载、错误、成功)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
权限模拟与 testGrantapps/studio/tests/lib/msw-permissions.ts, apps/studio/tests/lib/test-grant.ts
相关技能studio-testing(何时编写组件测试)、studio-queries(钩子约定)、vitest

评论

支持 Markdown