studio-testing について
studio-testing は、`apps/studio/` のテスト作成と構成に関するガイドです。テスト種別の選択やカバレッジの確認に役立ち、ロジックを純粋関数に分離して適切なテストを行う方針を示します。
主な機能
- 01コンポーネントのロジックを純粋関数に分離
- 02有効な入力と境界ケースを網羅
- 03操作の複雑さに応じたテスト選択
- 04共有機能に E2E テストを適用
- 05フェイクタイマーによるテスト時間短縮
活用シーン
- 01Studio の新機能向けテストの計画
- 02テストカバレッジの十分性の確認
- 03ロジックを個別テスト可能にするリファクタリング
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ supabase/ supabase --skill studio-testingインストール後、Claude Code で/studio-testingを入力して読み込まれたか確認します。
詳細
Studio のテスト戦略
apps/studio/ のテストの書き方と構成方法について説明します。基本方針は、React コンポーネントからロジックを純粋なユーティリティ関数に移し、それらの関数を網羅的にテストすることです。コンポーネントテストは、複雑な UI インタラクションにのみ使用します。セルフホスト版とプラットフォーム版の両方で共有される機能には、E2E テストを使用します。
適用する場面
以下の場合に、このガイドラインを参照してください。
- Studio のコードに新しいテストを書くとき
- どの種類のテスト(ユニット、コンポーネント、E2E)を書くかを決めるとき
- テスト可能にするため、コンポーネントからロジックを切り出すとき
- テストカバレッジが十分かどうかを確認するとき
- テストが必要な新機能を追加するとき
優先度別ルールカテゴリ
| 優先度 | カテゴリ | 影響度 | プレフィックス |
|---|---|---|---|
| 1 | ロジックの切り出し | CRITICAL | testing- |
| 2 | テストカバレッジ | CRITICAL | testing- |
| 3 | コンポーネントテスト | HIGH | testing- |
| 4 | E2E テスト | HIGH | testing- |
| 5 | テスト速度 | HIGH | testing- |
クイックリファレンス
1. ロジックの切り出し(CRITICAL)
testing-extract-logic- コンポーネントからロジックを取り除き、純粋関数として.utils.tsファイルに移します。引数を受け取り、値を返します。
2. テストカバレッジ(CRITICAL)
testing-exhaustive-permutations- ユーティリティ関数のあらゆる組み合わせをテストします。正常系、不正な入力、空の値、エッジケースを対象にします。
3. コンポーネントテスト(HIGH)
testing-component-tests-ui-only- ビジネスロジックではなく、複雑な UI インタラクションのロジックに対してのみコンポーネントテストを書きます。
4. E2E テスト(HIGH)
testing-e2e-shared-features- セルフホスト版とプラットフォーム版の両方で使われる機能に対して E2E テストを書きます。クリック操作とキーボードショートカットの両方を対象にします。
5. テスト速度(HIGH)
testing-node-environment- 純粋なロジックのテストは.test.tsに配置します(Node で実行されます)。jsdom を使用するのは.test.tsxのみです。testing-fake-timers- 実時間を待つことは避けます。デバウンスやポーリング間隔を過ぎるまで、フェイクタイマーを進めます。testing-paste-not-type- キー入力の動作自体をテストする場合を除き、user.pasteまたはfireEvent.changeで入力値を設定します。testing-scope-queries-*ByRoleによるオプションのクエリは、開いているリストボックスの範囲に限定します。testing-no-redundant-tests- ほぼ重複するテストはtest.eachにまとめます。失敗する可能性がないテストや、ライブラリ、クラス名、スナップショットのみを対象とするテストは削除します。
判断フロー:どのテストタイプを選ぶか?
Is the logic a pure transformation (parse, format, validate, compute)?
YES -> Extract to .utils.ts, write unit test with vitest
NO -> Does the feature involve complex UI interactions?
YES -> Is it used in both self-hosted and platform?
YES -> Write E2E test in e2e/studio/features/
NO -> Write component test with customRender
NO -> Can you extract the logic to make it pure?
YES -> Do that, then unit test it
NO -> Write a component test
1. ロジックをユーティリティファイルに切り出す(CRITICAL)
コンポーネントからできる限り多くのロジックを取り除きます。コンポーネントと同じ場所にある .utils.ts ファイルに、引数を受け取り、値を返す純粋関数として配置します。
ファイル名:
- ユーティリティ:コンポーネントの隣にある
ComponentName.utils.ts - テスト:ソースパスに対応する
tests/components/.../ComponentName.utils.test.ts
// ❌ Logic buried in component — hard to test without rendering
function TaxIdForm({ taxIdValue, taxIdName }: Props) {
const handleSubmit = () => {
const taxId = TAX_IDS.find((t) => t.name === taxIdName)
let sanitized = taxIdValue
if (taxId?.vatPrefix && !taxIdValue.startsWith(taxId.vatPrefix)) {
sanitized = taxId.vatPrefix + taxIdValue
}
submitToApi(sanitized)
}
return <form onSubmit={handleSubmit}>...</form>
}
// ✅ Logic extracted to .utils.ts — trivially testable
// TaxID.utils.ts
export function sanitizeTaxIdValue({ value, name }: { value: string; name: string }): string {
const taxId = TAX_IDS.find((t) => t.name === name)
if (taxId?.vatPrefix && !value.startsWith(taxId.vatPrefix)) {
return taxId.vatPrefix + value
}
return value
}
// TaxIdForm.tsx — thin shell
const handleSubmit = () => {
const sanitized = sanitizeTaxIdValue({ value: taxIdValue, name: taxIdName })
submitToApi(sanitized)
}
2. あらゆる組み合わせをテストする(CRITICAL)
ロジックを切り出したら、網羅的にテストします。すべてのコードパスにテストが必要です。
- 有効な入力(各分岐の正常系)
- 無効な入力/形式が不正な入力
- 空の値、null 値、欠落しているフィールド
- エッジケース(コロンを含むタイムスタンプ、特殊文字、境界値)
// ❌ Only happy path
test('parses a filter', () => {
expect(formatFilterURLParams('id:gte:20')).toStrictEqual({ column: 'id', operator: 'gte', value: '20' })
})
// ✅ Every permutation
test('parses valid filter', () => { ... })
test('handles timestamp with colons in value', () => { ... })
test('rejects malformed filter with missing parts', () => { ... })
test('rejects unrecognized operator', () => { ... })
test('allows empty filter value', () => { ... })
複数のケースで準備/実行/検証の形が共通する場合は、コピーしたテストを並べるのではなく、test.each のテーブルを1つ書きます。各行が1つの組み合わせに対応するため、テーブルを使うとテストの抜けを見つけやすくなります。テストしない内容については、セクション 5 を参照してください。
3. 複雑な UI のみコンポーネントテストを作成する(HIGH)
ユーティリティ関数のテストだけではカバーできない、複雑な UI 操作ロジックがある場合に限り、コンポーネントテストを作成します。
有効な理由: ユーザー操作の流れに応じた条件付きレンダリング、キーボード/マウスによるポップオーバーの開閉、複数ステップのフォーム遷移。
有効でない理由: コンポーネントに含まれているというだけの計算や変換。.utils.ts に切り出して、代わりにユニットテストを作成します。
Studio のコンポーネントテスト規約:
import { screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { HttpResponse } from 'msw'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
type OrganizationResponse = components['schemas']['OrganizationResponse']
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () => HttpResponse.json<OrganizationResponse[]>([]),
})
customRender(<MyComponent />)
expect(await screen.findByText('No organizations')).toBeInTheDocument()
- ネットワーク層で
addAPIMock(MSW)を使って API リクエストをモックします。未処理のリクエストがあるとテストは失敗します。vi.mock('@/data/...')は使わないでください。常にHttpResponse.json<…>に OpenAPI の body 型を渡してください。 customRenderは、Studio が必要とするプロバイダー(React Query、ルーターなど)でコンポーネントをラップします。- テンプレート全体、パスパラメーターの構文、jsdom/MSW の注意点については、
studio-mock-api-testsスキルを参照してください。
4. 共通機能には E2E テストを作成する(HIGH)
セルフホスト版とプラットフォーム版の両方に機能がある場合は、E2E テストを作成します。 マウスクリックとキーボードショートカット(Tab、Enter、Escape、矢印キー)の両方をテストします。
再利用可能な操作は e2e/studio/utils/*-helpers.ts に切り出します。リソースのクリーンアップには try/finally を使用します。E2E の実行方法については、studio-e2e-tests スキルを参照してください。
5. テストを高速に保つ(HIGH)
Studio には何千ものユニットテストがあり、ファイルごとに数秒かかるだけでも、すべての PR で数分に膨らみます。以下のパターンにより、シャーディング前の CI 実行時間を約 14 分から約 9 分に短縮できました。新しいテストすべてに適用してください。
純粋なロジックは .test.ts に記述し、node で実行する
vitest.config.ts は **/*.test.ts を node 環境で、**/*.test.tsx を jsdom で実行します。jsdom はファイルごとにセットアップに約 1 秒かかるため、次のようにします。
- ユーティリティ、スキーマ、リゾルバーのテストでは、ファイル名を
.test.tsにし、Testing Library から何もインポートしないでください。 - DOM が本当に必要な
.test.tsファイル(例:renderHook、window.localStorage)では、ドキュメントブロックを1 行目に記述してオプトインします。
// @vitest-environment jsdom
import { renderHook } from '@testing-library/react'
純粋なロジックのテストを .tsx に変更したり、「念のため」ドキュメントブロックを追加したりしないでください。DOM セットアップファイル(tests/setup/dom.ts、tests/setup/radix.js)は node 環境では何もしません。
実時間を待たない
setTimeout(resolve, …) でスリープしたり、ポーリング間隔を待つために findBy* のタイムアウトを延ばしたり、テストごとのタイムアウトを増やしたりしないでください。フェイクタイマーを使って時間を進めます。shouldAdvanceTime: true にすると、waitFor のポーリング、MSW、ルーターのマイクロタスクが動作します。
// ❌ Waits 4s+ of real time per poll (this file took 18s)
expect(
await screen.findByText('Restoration complete!', {}, { timeout: POLL_INTERVAL_MS + 2000 })
).toBeInTheDocument()
// ✅ Advance past the poll instead (same file now takes 0.3s)
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
const advancePoll = () => act(() => vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS))
await advancePoll()
expect(await screen.findByText('Restoration complete!')).toBeInTheDocument()
デバウンスも同様です。650ms スリープする代わりに、act(() => vi.advanceTimersByTimeAsync(650)) を使います。テストで値をハードコードしないよう、ソースからインターバル/デバウンス定数をエクスポートしてください。
入力は貼り付け、タイプしない
user.type は文字ごとに keydown/keypress/input/keyup を発生させ、それぞれがフォーム全体の再レンダリングを引き起こす可能性があります。キー入力単位の動作(入力マスク、ショートカット、キーごとのバリデーションなど)をテストするのでなければ、値を一度に設定してください。
// ❌ One re-render per character
await user.type(screen.getByPlaceholderText('Project name'), 'my-project')
// ✅ Focus then paste
await user.click(input)
await user.paste('my-project')
// ✅ Or a single change event
fireEvent.change(input, { target: { value: 'my-project' } })
userEvent が必要な場合は、userEvent.setup({ delay: null }) で作成し、操作ごとの遅延をなくしてください。
*ByRole クエリの範囲を絞る
*ByRole はすべての候補要素に対して(getComputedStyle を含む)アクセシビリティを確認します。Radix Select は項目ごとに非表示のネイティブ <option> をレンダリングするため、大きなフォームで範囲を絞らずに findByRole('option') を使うと、何百もの要素が確認対象になります。開いている listbox に範囲を絞ってください。
// ❌ Scans every hidden <option> on the page
await user.click(await screen.findByRole('option', { name: 'East US' }))
// ✅ Only the open listbox
const findOption = async (name: string | RegExp) =>
within(await screen.findByRole('listbox')).findByRole('option', { name })
await user.click(await findOption('East US'))
非常に大きなページでは、configure({ defaultHidden: true }) を使うと要素ごとの可視性チェックを省略でき、各 *ByRole クエリが約 3 倍高速になります。アサーションが可視性に依存しない場合に限り使用してください。
冗長なテストを書かない
テストを1つ追加するたびに、CI の実行時間が増えます。次のようなテストは追加しないでください。
- リテラルだけを変えて、別のテストと重複するもの。代わりに
test.eachの行を追加してください。 - 私たちのコードではなく、ライブラリをテストするもの(例:zod が誤った型を拒否することや、dayjs が日付をフォーマットすること)。
- クラス名やスナップショットを検証するもの。スタイルの変更で壊れ、動作上のバグも検出できません。
- カバレッジの数値を上げることだけを目的とするもの。
すべてのテストが失敗し得ることを確認してください。何があってもテストが通ってしまうよくある原因は、次のとおりです。
- 非同期アサーションを
awaitしていない(awaitなしのexpect(promise).resolves…やwaitFor(…))。 - フィクスチャがデフォルト値と同じため、「変更後」の経路が実行されない。
- 誤った関数を呼び出している(例:
semverGteのテストでsemverLteを呼び出している)。
簡単な確認方法:テスト対象のコードを壊し、テストが失敗することを確認してください。
コードベースの参照先
| 内容 | 場所 |
|---|---|
| ユーティリティのテスト例 | apps/studio/tests/components/Grid/Grid.utils.test.ts、apps/studio/tests/components/Billing/TaxID.utils.test.ts、apps/studio/tests/components/Editor/SpreadsheetImport.utils.test.ts |
| コンポーネントのテスト例 | apps/studio/tests/features/logs/LogsFilterPopover.test.tsx、apps/studio/tests/components/CopyButton.test.tsx |
| フェイクタイマー(ポーリング) | apps/studio/components/layouts/ProjectLayout/RestoringState.test.tsx |
| フェイクタイマー(デバウンス) | apps/studio/hooks/misc/__tests__/useTableEditorFiltersSort.navigation.test.tsx |
| スコープを指定したリストボックスのクエリ | apps/studio/tests/pages/new/[slug].test.tsx、apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/ResourceAccessStep.test.tsx |
| E2E テストの例 | e2e/studio/features/filter-bar.spec.ts |
| E2E ヘルパーのパターン | e2e/studio/utils/filter-bar-helpers.ts |
| カスタムレンダー | apps/studio/tests/lib/custom-render.tsx |
| MSW モックの設定 | apps/studio/tests/lib/msw.ts(addAPIMock) |
| テストの README | apps/studio/tests/README.md |
| Vitest の設定 | apps/studio/vitest.config.ts |
| 関連スキル | studio-e2e-tests(E2E の実行)、vitest(API リファレンス)、vercel-composition-patterns(コンポーネントアーキテクチャ) |