Best for
- The component (or any descendant it renders) calls a React Query hook
- You'd otherwise be tempted to write vi.mock('@/data/some-query', ...).
supabase/supabase/.claude/skills/studio-mock-api-tests/SKILL.md
Component tests for Supabase Studio that mock API requests at the network layer with MSW. Use when writing or reviewing a component test that exercises a React Query hook or mutation, or when migrating an existing test away from vi.mock('@/data/...'). Covers the customRender + addAPIMock template and the jsdom/MSW gotchas that cost real debugging time.
Decision brief
Mount a Studio component, intercept its network calls with MSW, assert what renders and what gets sent. The infrastructure is already wired up — this skill is the working template plus the gotchas.
Compatibility matrix
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
Installation
The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.
npx skills add https://github.com/supabase/supabase --skill ".claude/skills/studio-mock-api-tests"Inspect the Agent Skill "studio-mock-api-tests" from https://github.com/supabase/supabase/blob/9b3e0a9060854e13dba6b123f3c67af1ae618547/.claude/skills/studio-mock-api-tests/SKILL.md at commit 9b3e0a9060854e13dba6b123f3c67af1ae618547. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.
Workflow
If the component is purely presentational with no data fetching, you don't need MSW; render and assert directly.
That's the whole pattern. Server lifecycle (listen/resetHandlers/ close) is handled by apps/studio/tests/vitestSetup.ts — handlers registered via addAPIMock are scoped to the current test.
addAPIMock is typed from the OpenAPI paths, but path params are remapped to MSW's :param format. Autocomplete will guide you, but if typecheck reports the path isn't assignable, you're using the OpenAPI {slug} form.
addAPIMock is typed from the OpenAPI paths, but path params are remapped to MSW's :param format. Autocomplete will guide you, but if typecheck reports the path isn't assignable, you're using the OpenAPI {slug} form.
For success responses, always go through HttpResponse.json — even for 204/201-no-content endpoints. A raw new HttpResponse(null, { status: 201 }) returns no content-type, and openapi-fetch can hang the mutation flow, which silently breaks onSuccess callbacks.
Permission review
The documentation includes network, browsing, or remote request actions.
const limit = new URL(request.url).searchParams.get('limit')The documentation includes network, browsing, or remote request actions.
console.log('[MSW] UNHANDLED:', request.method, request.url)Evidence record
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 87/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 107,562 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated or reviewed according to the visible evidence level |
Pinned source
Mount a Studio component, intercept its network calls with MSW, assert what renders and what gets sent. The infrastructure is already wired up — this skill is the working template plus the gotchas.
/platform/..., /v1/..., or another endpoint
in apps/studio/data/api.d.ts.vi.mock('@/data/some-query', ...).
Don't. Mock the network instead — see "Why not vi.mock" below.If the component is purely presentational with no data fetching, you don't need MSW; render and assert directly.
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { describe, expect, test, vi } from 'vitest'
import { MyComponent } from './MyComponent'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
// 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()
})
})
That's the whole pattern. Server lifecycle (listen/resetHandlers/
close) is handled by apps/studio/tests/vitestSetup.ts — handlers
registered via addAPIMock are scoped to the current test.
:slug, not {slug}addAPIMock is typed from the OpenAPI paths, but path params are
remapped to MSW's :param format. Autocomplete will guide you, but if
typecheck reports the path isn't assignable, you're using the OpenAPI
{slug} form.
// ❌ TypeScript error, MSW won't match
path: '/platform/organizations/{slug}/projects'
// ✅ Correct
path: '/platform/organizations/:slug/projects'
HttpResponse.json, not new HttpResponseFor success responses, always go through HttpResponse.json — even for
204/201-no-content endpoints. A raw new HttpResponse(null, { status: 201 })
returns no content-type, and openapi-fetch can hang the mutation flow,
which silently breaks onSuccess callbacks.
// ❌ 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 })
fireEvent.clickThe convention <Button form={FORM_ID} type="submit" /> (button
outside the form, associated by id) doesn't reliably trigger submission
under userEvent.click in jsdom. Use fireEvent.click for the submit
button. Continue to use userEvent.type for inputs.
await userEvent.type(screen.getByPlaceholderText('value'), 'hello')
fireEvent.click(await screen.findByRole('button', { name: 'Save' }))
profileContextMany hooks (useOrganizationsQuery, anything in data/projects/,
anything that calls useProfile) refuse to fire until a profile is
loaded. Pass one explicitly:
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 })
useParams is globally mocked to { ref: 'default' }You don't need to mock the Next router for project-scoped components.
Just use 'default' as the project ref in your mock paths:
/v1/projects/default/secrets, /platform/projects/default/.... If
you need a different ref, override with routerMock.setCurrentUrl(...)
(see apps/studio/tests/lib/route-mock.ts).
mswServer.listen({ onUnhandledRequest: 'error' }) is set globally. If a
component (or any child it renders) fires an unmocked request, you'll see
MSW errors in stderr and likely flaky behavior. Cards, lists, and details
panels often fire nested queries (e.g. OrganizationCard calls
useOrgProjectsInfiniteQuery) — read what the rendered subtree does and
mock all of it, or stub it with vi.mock for nested components only.
pathaddAPIMock accepts ?foo=bar suffixes via TrimQueryParams, but the
helper strips them before matching. MSW v2 doesn't match query params via
path strings — read them inside the resolver instead:
addAPIMock({
method: 'get',
path: '/platform/projects',
response: ({ request }) => {
const limit = new URL(request.url).searchParams.get('limit')
// ...
},
})
HttpResponse.jsonaddAPIMock's resolver is typed against the OpenAPI success body (and the
standard { message: string } error envelope, exported as APIErrorBody).
But MSW's HttpResponse.json uses NoInfer, so the body type doesn't
narrow from context. Pass the expected shape explicitly — it doubles as a
self-documenting contract assertion:
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
response: () => HttpResponse.json<OrganizationResponse[]>([...])
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Boom' }, { status: 500 })
A mock that drifts from the contract (wrong envelope, missing fields, stale enum values) now fails at compile time, not at runtime. The cost is one type annotation per resolver — well worth it.
For mocks at the network boundary, also prefer createMockOrganizationResponse
(returns the raw OpenAPI OrganizationResponse) over createMockOrganization
(which extends with frontend-derived managed_by / partner_id that the
query layer attaches). Same pattern applies to any type that's a frontend
extension of an OpenAPI schema: build a createMockXResponse helper that
returns the raw API shape.
MSW's own best-practices doc explicitly recommends asserting on what
renders, not on whether a handler was called. The "did the form
submit?" question is best answered by expect(onClose).toHaveBeenCalled()
or by findByText('Saved') — not by spying on the resolver.
There's one legitimate exception: the request body itself is the
contract you care about, and the server's reply doesn't reflect it
back. Bulk-create endpoints (like POST /v1/projects/:ref/secrets) are
the canonical case — 201 with no body, so the only way to verify the
shape sent is to capture it:
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' }] }])
When in doubt, assert on the UI first; reach for request capture only when the UI doesn't observably encode the contract.
If a request isn't being matched, wire up MSW's lifecycle events at the
top of the test file (or temporarily in msw.ts):
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 is already wired in msw.ts. Add request:unhandled and
response:mocked locally when a test misbehaves — usually surfaces a
path-param mismatch or a nested query you forgot to mock.
vi.mock('@/data/...')It bypasses the network boundary, so:
onMutate, onSuccess, and onError callbacks won't run as
they do in real life. (tkdodo.eu/blog/testing-react-query)Reach for vi.mock only for non-network concerns: a heavy child
component (e.g. a Monaco editor) you want to stub, or a common-package
hook with global state.
server.use() pattern.| What | Where |
|---|---|
| Query-only example (loading, error, success) | apps/studio/components/interfaces/Organization/OrgNotFound.test.tsx |
| Mutation example (form, payload assertion) | apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.test.tsx |
SQL-via-pg-meta example (POST resolver branch on query body) | apps/studio/components/interfaces/Integrations/Vault/Secrets/__tests__/EditSecretModal.test.tsx |
addAPIMock source | apps/studio/tests/lib/msw.ts |
customRender source | apps/studio/tests/lib/custom-render.tsx |
| Global handlers + lifecycle | apps/studio/tests/lib/msw-global-api-mocks.ts, apps/studio/tests/vitestSetup.ts |
| Related skills | studio-testing (when to write a component test at all), studio-queries (hook conventions), vitest |
Alternatives
AI-Unified-Process/marketplace
Creates Vitest component tests for Angular views using Angular's own testing idioms — TestBed, ComponentFixture, and HttpTestingController — not React Testing Library patterns. Use when the user asks to "write frontend tests", "test the Angular component", "write a Vitest test", "unit test an Angular page", or mentions TestBed, HttpTestingController, or component testing for this stack.
PramodDutta/qaskills
Comprehensive testing patterns for Zod schemas covering validation testing, transform testing, error message verification, and integration with API endpoints and forms
PramodDutta/qaskills
Testing patterns for TanStack Query (React Query) covering query hook testing, mutation testing, cache behavior testing, and optimistic update verification.
day8/re-frame2
Writes, reviews, and debugs view code for `re-frame.ui` (artefact `day8/re-frame2-ui`, alias `ui/`) — re-frame2's compiled-view substrate, now the DONOR being absorbed into Freehand. Use it for an app that ALREADY depends on `day8/re-frame2-ui`; for new view work on the re-frame-native view layer, or for porting Reagent views onto it, route to `reagent-migration` and `spec/004-Views.md` instead — `defview` components, the closed template grammar, reactive `(sub …)` reads, handlers-as-data (event