MCPInstall

studio-mock-api-tests

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.

Installs
124
Last 24h
—
Trending rank
—
Security
Not audited

Install

Works with every agent the skills CLI supports.

npx skills add https://github.com/supabase/supabase --skill studio-mock-api-tests
  1. 01

    Install

    Run the command in your project. The CLI asks which agents to install for.

  2. 02

    Describe the task

    Ask your agent for something this skill covers. It loads studio-mock-api-tests automatically when relevant.

  3. 03

    Review the result

    The agent follows the SKILL.md steps, scripts and references bundled with the skill.

SKILL.md

The instructions your agent reads when the skill is triggered.

Studio MSW component tests

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.

When to use

  • The component (or any descendant it renders) calls a React Query hook or mutation that hits /platform/..., /v1/..., or another endpoint in apps/studio/data/api.d.ts.
  • You'd otherwise be tempted to write 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.

The template

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()
  })
})

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.

Gotchas that will eat your afternoon

1. Path params use :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'

2. Use HttpResponse.json, not new HttpResponse

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.

// ❌ 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. Submit buttons in Sheets/Modals need fireEvent.click

The 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. If that still doesn't submit, call fireEvent.submit(document.getElementById(FORM_ID)!) directly. Fill inputs with user.paste rather than user.type (see "Keep Tests Fast" in studio-testing).

await user.click(screen.getByPlaceholderText('value'))
await user.paste('hello')
fireEvent.click(await screen.findByRole('button', { name: 'Save' }))

4. Profile-gated queries need a profileContext

Many 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: '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 })

5. 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).

6. Unhandled requests fail loudly — mock every endpoint a render triggers

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.

7. Don't put query strings in the handler path

addAPIMock 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')
    // ...
  },
})

8. Always pass an explicit generic to HttpResponse.json

addAPIMock'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.

9. Mock FGA permissions with mockPermissions

Components that check permissions (useGrant, useAsyncCheckPermissionsV2) request /platform/profile/permissions/v2. There's no global default — mock it in each test that needs it:

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

Unit tests of fetchers or query options that take a grant can use testGrant(endpoint, { projectRef }) from @/tests/lib/test-grant instead of going through permissions at all.

Prefer asserting on UI state

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.

Debugging an MSW test

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.

Why not vi.mock('@/data/...')

It bypasses the network boundary, so:

  • It hides real bugs: a renamed query key or a changed request payload passes the test, then breaks in production.
  • It doesn't exercise React Query's caching, retry, or invalidation paths — onMutate, onSuccess, and onError callbacks won't run as they do in real life. (tkdodo.eu/blog/testing-react-query)
  • It drifts independently from the OpenAPI types — handlers stay in sync, 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.

Further reading

Codebase references

WhatWhere
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 sourceapps/studio/tests/lib/msw.ts
customRender sourceapps/studio/tests/lib/custom-render.tsx
Global handlers + lifecycleapps/studio/tests/lib/msw-global-api-mocks.ts, apps/studio/tests/vitestSetup.ts
Permissions mocks + testGrantapps/studio/tests/lib/msw-permissions.ts, apps/studio/tests/lib/test-grant.ts
Related skillsstudio-testing (when to write a component test at all), studio-queries (hook conventions), vitest

FAQ

What does the studio-mock-api-tests skill do?

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.

How do I install studio-mock-api-tests?

Run npx skills add https://github.com/supabase/supabase --skill studio-mock-api-tests in your project. The skills CLI detects the agents you use and copies the skill into the right folder. For Claude Code you can also place the skill folder at ~/.claude/skills/studio-mock-api-tests/ (personal) or .claude/skills/studio-mock-api-tests/ (project).

Which AI agents can use studio-mock-api-tests?

Any agent that supports the open Agent Skills format (a folder with a SKILL.md file), including Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Windsurf, Cline, OpenCode and more.

Is studio-mock-api-tests safe to install?

No partner security audit is available yet. Skills can run commands and scripts, so read the SKILL.md and any bundled files before installing.

Who maintains studio-mock-api-tests?

It is published by supabase in supabase/supabase. It has 124 recorded installs.

More from supabase/supabase

View all →

vitest

supabase/supabase

Vitest API and config reference (Jest-compatible) — mocking with vi., spies, fake timers, coverage configuration, fixtures, snapshots, and test filtering. Use for Vitest API and configuration questions anywhere in the monorepo; for Studio-specific test strategy and component-test setup, start with studio-testing and studio-mock-api-tests.

OfficialTesting & QA2.8K installs

React composition patterns that scale. Use when refactoring components with boolean prop proliferation, building flexible component libraries, or designing reusable APIs. Triggers on tasks involving compound components, render props, context providers, or component architecture. Includes React 19 API changes.

OfficialDevOps & Cloud1.9K installs

studio-testing

supabase/supabase

Open the skill to read its SKILL.md instructions.

OfficialTesting & QA327 installs

telemetry-standards

supabase/supabase

Open the skill to read its SKILL.md instructions.

OfficialData & Databases292 installs

studio-error-handling

supabase/supabase

Open the skill to read its SKILL.md instructions.

OfficialData & Databases253 installs

studio-ui-patterns

supabase/supabase

Open the skill to read its SKILL.md instructions.

OfficialFrontend & Design227 installs

Popular testing & qa skills

View category →

tdd

mattpocock/skills

Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions "red-green-refactor", or wants integration tests.

Testing & QA1.2M installs

grilling

mattpocock/skills

Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.

Testing & QA996.2K installs

migrate-to-shoehorn

mattpocock/skills

Migrate test files from as type assertions to @total-typescript/shoehorn. Use when user mentions shoehorn, wants to replace as in tests, or needs partial test data.

Testing & QA484.1K installs

run-train

lllllllama/rigorpilot-skills

Rigor Train skill for deep learning research repositories. Use when a documented or selected training command should be run conservatively for startup verification, short-run verification, full kickoff, or resume, with command, config, seed, log, checkpoint, status, and metric evidence written to standardized trainoutputs/. Do not use for environment setup, exploratory sweeps, speculative idea implementation, or end-to-end orchestration.

Testing & QA337.8K installs

test-driven-development

obra/superpowers

Use when implementing any feature or bugfix, before writing implementation code

Testing & QA261.8K installs

Use when implementation is complete, all tests pass, and you need to decide how to integrate the work

Testing & QA216.8K installs