studio-mock-api-tests について
studio-mock-api-tests は、`apps/studio/` のコンポーネントテスト向け MSW 実践ガイドです。データ取得やミューテーションをテストする開発者に、ネットワーク層でのリクエスト処理、型制約、トラブルシューティングを説明します。
主な機能
- 01MSW によるネットワークリクエストのインターセプト
- 02customRender によるコンポーネントのマウント
- 03OpenAPI 型に沿ったレスポンス作成
- 04クエリとミューテーションのフローをテスト
- 05描画された UI 状態をアサート
活用シーン
- 01API データを取得するコンポーネントのテスト
- 02フォーム送信後の UI 動作確認
- 03MSW のリクエスト照合問題の調査
インストール
コマンドをコピーしてターミナルで実行します。
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. new HttpResponse ではなく HttpResponse.json を使う
成功レスポンスには、必ず 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.type ではなく user.paste で入力します(studio-testing の「テストを高速に保つ」を参照)。
await user.click(screen.getByPlaceholderText('value'))
await user.paste('hello')
fireEvent.click(await screen.findByRole('button', { name: 'Save' }))
4. プロファイルが必要なクエリには profileContext を渡す
多くのフック(useOrganizationsQuery、data/projects/ 内のすべてのもの、useProfile を呼び出すもの)は、プロファイルが読み込まれるまで実行されません。明示的に渡してください。
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 ルーターをモックする必要はありません。モックパスのプロジェクト ref には 'default' を使ってください。
/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 では、path 文字列を使ってクエリーパラメーターをマッチングできません。代わりに、リゾルバー内で読み取ってください。
addAPIMock({
method: 'get',
path: '/platform/projects',
response: ({ request }) => {
const limit = new URL(request.url).searchParams.get('limit')
// ...
},
})
8. HttpResponse.json には必ず明示的なジェネリック型を渡す
addAPIMock のリゾルバーは、OpenAPI の成功レスポンスボディ(および APIErrorBody としてエクスポートされている標準の { message: string } エラーエンベロープ)に対して型付けされています。しかし、MSW の HttpResponse.json は NoInfer を使用するため、コンテキストからボディの型が絞り込まれません。期待する形を明示的に渡してください。これにより、契約を自己文書化するアサーションも兼ねられます。
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
response: () => HttpResponse.json<OrganizationResponse[]>([...])
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Boom' }, { status: 500 })
契約からずれたモック(エンベロープの誤り、フィールドの不足、古い列挙値)は、実行時ではなくコンパイル時にエラーになります。コストはリゾルバーごとに型注釈を1つ付けるだけです。十分に価値があります。
ネットワーク境界のモックでは、createMockOrganization(クエリレイヤーが付加するフロントエンド由来の managed_by / partner_id を拡張する)よりも、createMockOrganizationResponse(生の OpenAPI の OrganizationResponse を返す)を優先してください。同じ方法は、OpenAPI スキーマをフロントエンド側で拡張したあらゆる型にも適用できます。生の API 形式を返す createMockXResponse ヘルパーを作成してください。
9. FGA の権限は mockPermissions でモックする
権限を確認するコンポーネント(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 を受け取るフェッチャーやクエリーオプションの単体テストでは、権限を介さずに @/tests/lib/test-grant の testGrant(endpoint, { projectRef }) を使用できます。
UI の状態を検証する
MSW 自身のベストプラクティスのドキュメントでは、ハンドラーが呼び出されたかどうかではなく、何がレンダリングされるかを検証することが明示的に推奨されています。「フォームが送信されたか」という問いには、リゾルバーをスパイするのではなく、expect(onClose).toHaveBeenCalled() または findByText('Saved') で答えるのが最適です。
正当な例外が1つあります。リクエストボディ自体が検証したい契約であり、サーバーの応答にその内容が反映されない場合です。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 の型とは独立して乖離します。ハンドラーは同期を保ちますが、モジュールレベルのモックはそうではありません。
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 |