studio-e2e-tests について
Studio アプリの Playwright エンドツーエンドテストを実行・保守するためのスキルガイドです。堅牢なセレクター、対象を絞った待機、CI のコールドスタート状態の再現に重点を置いています。
主な機能
- 01全件または絞り込んだ E2E テストの実行
- 02Playwright UI モードでのデバッグ
- 03アクセシブルで範囲を絞ったセレクター
- 04操作前の API 待機設定
- 05CI のコールドスタート状態の再現
活用シーン
- 01Studio の機能テストをローカルで実行
- 02不安定な UI・API テストの調査
- 03堅牢なセレクターとアサーションの作成
- 04CI でのみ発生する失敗の再現
MCP 対応
ローカルデバッグ時の UI 検査に Playwright MCP ツールを使う方法が記載されています。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ supabase/ supabase --skill studio-e2e-testsインストール後、Claude Code で/studio-e2e-testsを入力して読み込まれたか確認します。
詳細
E2E Studio テスト
Studio アプリケーションの Playwright エンドツーエンドテストを実行します。
テストの実行
テストは e2e/studio ディレクトリから実行する必要があります。
cd e2e/studio && pnpm run e2e
特定のファイルを実行
cd e2e/studio && pnpm run e2e -- features/cron-jobs.spec.ts
grep フィルターを使って実行
cd e2e/studio && pnpm run e2e -- --grep "test name pattern"
デバッグ用の UI モード
cd e2e/studio && pnpm run e2e -- --ui
環境のセットアップ
- テストは Web サーバー設定を介して Supabase のローカルコンテナーを自動起動します
- セルフホストモード(
IS_PLATFORM=false)ではテストを並列実行します(3 ワーカー) - セルフホストのテストでは手動でのセットアップは不要です
テストファイルの構成
- テストは
e2e/studio/features/*.spec.tsにあります - カスタムテストユーティリティを使用します:
import { test } from '../utils/test.js' - テストフィクスチャには
page、ref、その他のヘルパーが用意されています
よく使われるパターン
余裕のあるタイムアウトで要素を待ちます。
await expect(locator).toBeVisible({ timeout: 30000 })
データベースの状態を共有するテストには、シリアルモードを使用します。
test.describe.configure({ mode: 'serial' })
堅牢なセレクターの記述
セレクターの優先順位(高いものから低いもの)
-
アクセシブルな名前を指定した
getByRole- 最も堅牢で、アクセシビリティをテストできますpage.getByRole('button', { name: 'Save' }) page.getByRole('button', { name: 'Configure API privileges' }) -
getByTestId- 安定した明示的なテストフックですpage.getByTestId('table-editor-side-panel') -
完全一致する
getByText- 一意のテキストに適していますpage.getByText('Data API access', { exact: true }) -
CSS を使った
locator- 使用は控えめにしてください。より壊れやすい方法ですpage.locator('[data-state="open"]')
避けるべきパターン
-
XPath セレクター - DOM の変更に弱い方法です
// BAD locator('xpath=ancestor::div[contains(@class, "space-y")]') -
locator('..')による親要素への移動 - 構造が変わると動作しなくなります// BAD element.locator('..').getByRole('button') -
汎用要素に対する広範な
filter({ hasText })- 複数の要素に一致する場合があります// BAD - popover may have more than one combobox // Could consider scoping down the container or filtering the combobox more specifically popover.getByRole('combobox')
コンポーネントにアクセシブルなラベルを追加する
コンポーネントに適切なアクセシブル名がない場合は、ソースコードに追加します。
// In the React component
<Button aria-label="Configure API privileges">
<Settings />
</Button>
その後、テストで使用します。
page.getByRole('button', { name: 'Configure API privileges' })
検索範囲の絞り込み
誤った要素に一致しないよう、セレクターの対象を特定のコンテナーに絞ります。
// Good - scoped to side panel
const sidePanel = page.getByTestId('table-editor-side-panel')
const toggle = sidePanel.getByRole('switch')
// Good - find unique element, then scope from there
const popover = page.locator('[data-radix-popper-content-wrapper]')
const roleSection = popover.getByText('Anonymous (anon)', { exact: true })
競合状態の回避
アクションを実行する前に API の待機処理を設定してください。 不安定なテストの最も一般的な原因です。
// ❌ Race condition — response may complete before waiter is set up
await page.getByRole('button', { name: 'Save' }).click()
await waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
// ✅ Waiter is ready before the action
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
await page.getByRole('button', { name: 'Save' }).click()
await apiPromise
同じルールは、ナビゲーションの前にも適用されます。
const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
アクションによって複数の API 呼び出しが発生する場合は、すべての呼び出しを待ちます。
const createTablePromise = waitForApiResponseWithTimeout(page, (r) =>
r.url().includes('query?key=table-create')
)
const tablesPromise = waitForApiResponseWithTimeout(page, (r) =>
r.url().includes('tables?include_columns=true')
)
await page.getByRole('button', { name: 'Save' }).click()
await Promise.all([createTablePromise, tablesPromise])
待機方法
Playwright は操作可能な状態になるまで要素を自動で待機します。手動のタイムアウトよりもこちらを優先してください。
動的な状態の変化には expect.poll を使用します。
await expect.poll(async () => await page.getByLabel(`View ${tableName}`).count()).toBe(0)
要素のライフサイクルには、状態を指定して waitForSelector を使用します。
await page.waitForSelector('[data-testid="side-panel"]', { state: 'detached' })
networkidle は避け、代わりに特定の API を待機します。
// ❌ Unreliable and slow
await page.waitForLoadState('networkidle')
// ✅ Specific API response
await waitForApiResponse(page, 'pg-meta', ref, 'tables')
waitForTimeout の使用が許容されるのは、クライアント側のデバウンスの場合のみです。
await page.getByRole('textbox').fill('search term')
await page.waitForTimeout(300) // allow debounce
waitForTimeout の回避
UI やネットワークを待つために waitForTimeout を使用しないでください。必ず特定の対象を待機します(前述のデバウンスの場合のみ例外です)。
// BAD
await page.waitForTimeout(1000)
// GOOD - wait for UI element
await expect(page.getByText('Success')).toBeVisible()
// GOOD - wait for API response
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
await saveButton.click()
await apiPromise
// GOOD - wait for toast indicating operation complete
await expect(page.getByText('Table created successfully')).toBeVisible({ timeout: 15000 })
クリック時の force: true を避ける
非表示の要素を強制的にクリックするのではなく、まず表示させてください。
// BAD
await menuButton.click({ force: true })
// GOOD - hover to reveal, then click
await tableRow.hover()
await expect(menuButton).toBeVisible()
await menuButton.click()
テストの構成
必ずカスタムテストユーティリティからインポートします。
import { test } from '../utils/test.js'
ファイルごとに一度だけ実行する高コストなセットアップには、withFileOnceSetup を使用します。
test.beforeAll(async ({ browser, ref }) => {
await withFileOnceSetup(import.meta.url, async () => {
const ctx = await browser.newContext()
const page = await ctx.newPage()
await deleteTestTables(page, ref)
})
})
test.afterAll(async () => {
await releaseFileOnceCleanup(import.meta.url)
})
操作の前にトーストを閉じてください。トーストがボタンに重なることがあります。
const dismissToastsIfAny = async (page: Page) => {
const closeButtons = page.getByRole('button', { name: 'Close toast' })
const count = await closeButtons.count()
for (let i = 0; i < count; i++) {
await closeButtons.nth(i).click()
}
}
await dismissToastsIfAny(page)
await page.getByRole('button', { name: 'New table' }).click()
アサーション
デバッグしやすいように、必ず説明的なメッセージを含めます。
// ❌ No context on failure
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
// ✅ Clear message on failure
await expect(
page.getByRole('button', { name: 'Save' }),
'Save button should be visible after form is filled'
).toBeVisible()
時間のかかる操作には、明示的なタイムアウトを指定します。
await expect(
page.getByText(`Table ${tableName} is good to go!`),
'Success toast should be visible after table creation'
).toBeVisible({ timeout: 50000 })
ヘルパー関数
再利用可能な操作は、ドメインヘルパー(例:e2e/studio/utils/storage-helpers.ts)に切り出します。
既存の待機ユーティリティを使用します。
import {
createApiResponseWaiter,
waitForApiResponse,
waitForGridDataToLoad,
waitForTableToLoad,
} from '../utils/wait-for-response.js'
ハードコードされたタイムアウトでクリップボードを手動で読み取る代わりに、expectClipboardValue を使用します。
// ❌ Brittle
await page.evaluate(() => navigator.clipboard.readText())
await page.waitForTimeout(500)
// ✅ Uses Playwright auto-retries
await expectClipboardValue({ page, value: 'expectedValue' })
API モック
await page.route('*/**/logs.all*', async (route) => {
await route.fulfill({ body: JSON.stringify(mockAPILogs) })
})
任意の API 呼び出しには、ソフトウェイトを使用します。
await waitForApiResponse(page, 'pg-meta', ref, 'optional-endpoint', {
soft: true,
fallbackWaitMs: 1000,
})
クリーンアップ
beforeAll/beforeEach でテストデータをクリーンアップします。既存の状態を適切に処理できるよう、削除する前に確認します。
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
if ((await bucketRow.count()) === 0) return
// proceed with deletion
ローカルストレージを変更するテストの後は、ローカルストレージをリセットします。
import { resetLocalStorage } from '../utils/reset-local-storage.js'
await resetLocalStorage(page, ref)
デバッグ
トレースの表示
cd e2e/studio && pnpm exec playwright show-trace <path-to-trace.zip>
HTML レポートの表示
cd e2e/studio && pnpm exec playwright show-report
エラーコンテキスト
エラーコンテキストファイルは test-results/ ディレクトリに保存されます。
Playwright MCP ツール
ローカルでのデバッグ時に UI を調査するには、Playwright MCP ツールを使用します。
CI とローカル開発の違い
重要な違いは、コールドスタートとウォーム状態です。
CI(コールドスタート)
テストは空のデータベースから実行されます。テストの実行ごとにデータベースがリセットされ、新しいコンテナが起動します。pg_cron のような拡張機能は、デフォルトでは有効になっていません。
pnpm dev:studio-local を使ったローカル開発
開発サーバーを起動した状態でデバッグすると、データベースに以前の実行時の状態(有効になった拡張機能やテストデータ)が残っている場合があります。
コールドスタートのバグへの対処
ローカルでは動作するのに CI で失敗するテストは、多くの場合、既存の状態を前提としています。
よくある問題
- 拡張機能が有効になっていない(テストのセットアップで有効にする必要があります)
- 並列テストが共有状態を変更しようとしたときに発生する競合状態(
test.describe.configure({ mode: 'serial' })を使用します) - 状態がセットアップされていない場合にページ構造が異なるため、ロケーターが誤った要素に一致する
CI の動作をローカルで再現する
テストフレームワークは、pnpm run e2e の実行時にデータベースを自動的にリセットします。これにより、CI の動作を再現できます。
Playwright MCP のデバッグに pnpm dev:studio-local を使用する場合は、CI とは状態が異なることに注意してください。
CI 失敗時のデバッグワークフロー
- まず、
pnpm run e2e -- features/<file>.spec.tsでテストをローカル実行します(コールドスタート)。 test-results/ディレクトリでエラーのコンテキストを確認します。- UI の状態を確認する必要がある場合は、
pnpm dev:studio-localを起動し、Playwright MCP ツールを使用します。 - 開発サーバーで見える状態は、CI には存在しない場合があることを忘れないでください。