Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/supabase-studio-mock-api-tests/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 109% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 89% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 12% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 127% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 96% | 0% |
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.
or mutation that hits /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.
tsximport { 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() }) })
That's the whole pattern (add fireEvent, waitFor, or userEvent as the interactions need them — see the gotchas below). 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.
ts// ❌ 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.
ts// ❌ 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.
tsawait 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:
tsimport type { ProfileContextType } from '@/lib/profile' const PROFILE_CONTEXT: ProfileContextType = { profile: { id: 1, auth0_id: 'auth0|test', gotrue_id: 'gotrue-test', username: 'testuser', primary_email: 'test@example.com', 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:
tsaddAPIMock({ 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:
tsimport { 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:
tsconst 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):
tsimport { 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:
passes the test, then breaks in production.
paths — onMutate, onSuccess, and onError callbacks won't run as they do in real life. (tkdodo.eu/blog/testing-react-query)
module-level mocks don't.
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.
canonical reference for the principles behind everything in this skill.
and overriding network behavior — the baseline-handlers + per-test-server.use() pattern.
the source of the "assert on UI state" guidance above.
| 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 |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 13,340 | 24,840 | +86% | 1 | 1 | 0% | 2,642 | 5,533 | +109% | 0 | 0 | — |
case-02 | fail→pass | 16,543 | 15,053 | -9% | 1 | 1 | 0% | 3,162 | 5,979 | +89% | 0 | 0 | — |
case-03 | fail→pass | 23,422 | 12,924 | -45% | 1 | 1 | 0% | 4,939 | 5,530 | +12% | 0 | 0 | — |
case-04 | pass→pass | 10,628 | 8,590 | -19% | 1 | 1 | 0% | 1,897 | 4,428 | +133% | 0 | 0 | — |
case-05 | fail→pass | 12,177 | 9,181 | -25% | 1 | 1 | 0% | 2,023 | 4,584 | +127% | 0 | 0 | — |
case-06 | fail→pass | 13,034 | 8,130 | -38% | 1 | 1 | 0% | 2,327 | 4,550 | +96% | 0 | 0 | — |
case-07 | pass→pass | 14,844 | 5,776 | -61% | 1 | 1 | 0% | 2,688 | 4,097 | +52% | 0 | 0 | — |
case-08 | pass→pass | 15,289 | 7,128 | -53% | 1 | 1 | 0% | 2,837 | 4,475 | +58% | 0 | 0 | — |
case-09 | fail→fail | 9,974 | 5,191 | -48% | 1 | 1 | 0% | 1,665 | 3,929 | +136% | 0 | 0 | — |
case-10 | fail→pass | 15,328 | 2,451 | -84% | 1 | 1 | 0% | 2,458 | 3,341 | +36% | 0 | 0 | — |
case-11 | pass→pass | 12,431 | 4,812 | -61% | 1 | 1 | 0% | 2,073 | 3,711 | +79% | 0 | 0 | — |
case-12 | fail→pass | 14,871 | 10,224 | -31% | 1 | 1 | 0% | 2,431 | 4,913 | +102% | 0 | 0 | — |
case-13 | fail→pass | 13,810 | 3,589 | -74% | 1 | 1 | 0% | 2,263 | 3,551 | +57% | 0 | 0 | — |
case-14 | fail→pass | 10,437 | 5,080 | -51% | 1 | 1 | 0% | 1,873 | 3,920 | +109% | 0 | 0 | — |
case-15 | fail→pass | 15,240 | 6,899 | -55% | 1 | 1 | 0% | 2,171 | 4,190 | +93% | 0 | 0 | — |
case-16 | pass→pass | 13,351 | 9,901 | -26% | 1 | 1 | 0% | 2,384 | 4,784 | +101% | 0 | 0 | — |
case-17 | pass→pass | 13,565 | 6,755 | -50% | 1 | 1 | 0% | 2,095 | 4,137 | +97% | 0 | 0 | — |
case-18 | pass→pass | 8,979 | 4,374 | -51% | 1 | 1 | 0% | 1,305 | 3,783 | +190% | 0 | 0 | — |
case-19 | pass→pass | 12,497 | 6,009 | -52% | 1 | 1 | 0% | 2,190 | 3,925 | +79% | 0 | 0 | — |
case-20 | pass→pass | 9,613 | 4,175 | -57% | 1 | 1 | 0% | 1,502 | 3,708 | +147% | 0 | 0 | — |
case-21 | pass→pass | 12,671 | 8,485 | -33% | 1 | 1 | 0% | 2,556 | 4,451 | +74% | 0 | 0 | — |
case-22 | pass→pass | 7,054 | 5,838 | -17% | 1 | 1 | 0% | 1,235 | 4,014 | +225% | 0 | 0 | — |
case-23 | fail→pass | 17,268 | 3,924 | -77% | 1 | 1 | 0% | 2,193 | 3,739 | +70% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +48 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.