关于 studio-e2e-tests
一份用于运行和维护 Studio 应用 Playwright 端到端测试的技能指南,重点介绍可靠的选择器、针对性等待,以及复现 CI 冷启动状态。
主要功能
- 01运行完整或筛选后的 E2E 测试
- 02使用 Playwright UI 模式调试
- 03采用可访问且范围明确的选择器
- 04在操作前设置 API 等待
- 05复现 CI 冷启动状态
适用场景
- 01本地运行 Studio 功能测试
- 02排查不稳定的 UI 或 API 测试
- 03编写可靠的选择器和断言
- 04复现仅在 CI 出现的故障
MCP 相关
指南提及使用 Playwright MCP 工具,在本地调试时检查 UI。
安装
复制下面的命令,在终端中运行。
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 server 配置自动启动 Supabase 本地容器
- 可自托管模式(
IS_PLATFORM=false)下,测试会并行运行(3 个 worker) - 可自托管测试无需手动设置
测试文件结构
- 测试位于
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
绝不要使用 waitForTimeout 等待 UI 或网络 — 始终等待特定事件(上文的防抖情况是唯一例外):
// 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 工具
在本地调试时,使用 Playwright MCP 工具检查 UI。
CI 与本地开发
主要区别在于冷启动与预热状态:
CI(冷启动)
测试从空白数据库开始运行。每次测试运行都会重置数据库,并启动全新的容器。默认情况下不会启用 pg_cron 等扩展。
使用 pnpm dev:studio-local 进行本地开发
使用运行中的开发服务器进行调试时,数据库可能已保留之前运行的状态(已启用扩展、存在测试数据)。
处理冷启动问题
本地运行正常但在 CI 中失败的测试,通常是因为对已有状态存在假设。
常见问题
- 未启用扩展(必须在测试设置中启用)
- 并行测试尝试修改共享状态时发生竞态条件(使用
test.describe.configure({ mode: 'serial' })) - 页面结构因状态未设置而不同,导致定位器匹配到错误的元素
在本地复现 CI 行为
运行 pnpm run e2e 时,测试框架会自动重置数据库。这与 CI 的行为一致。
如果使用 pnpm dev:studio-local 进行 Playwright MCP 调试,请记住其状态与 CI 不同。
CI 故障的调试工作流
- 首先,使用
pnpm run e2e -- features/<file>.spec.ts在本地运行测试(冷启动) - 查看
test-results/目录中的错误上下文 - 如果需要检查 UI 状态,请启动
pnpm dev:studio-local并使用 Playwright MCP 工具 - 请记住:你在开发服务器中看到的内容可能包含 CI 中不存在的状态