Files
ComfyUI_frontend/.claude/skills/writing-playwright-tests/reference/playwright-best-practices.md
bymyself c257d0dd34 docs: add strict mode violation anti-pattern to best practices
Document common pitfall with getByText() matching multiple elements
(tabs, headers, settings sharing terminology). Show fixes using
getByRole, exact match, and container scoping.

Amp-Thread-ID: https://ampcode.com/threads/T-019c16eb-8621-7473-9062-a57b0a1e782a
2026-02-03 16:54:38 -08:00

6.2 KiB

Playwright Best Practices

Official Playwright patterns. ComfyUI-specific patterns in ../SKILL.md.

Locator Priority

Use locators in this order of preference:

Priority Method Use Case
1 page.getByRole() Buttons, links, headings (accessibility)
2 page.getByLabel() Form controls with labels
3 page.getByPlaceholder() Inputs by placeholder
4 page.getByText() Elements by text content
5 page.getByAltText() Images by alt text
6 page.getByTitle() Elements by title attribute
7 page.getByTestId() data-testid fallback
// ✅ Good - uses role and accessible name
await page.getByRole('button', { name: 'Submit' }).click()

// ❌ Bad - tied to implementation details
await page.locator('button.btn-primary.submit-btn').click()

Web-First Assertions

Always use auto-retrying assertions:

// ✅ Good - auto-waits and retries
await expect(page.getByText('Welcome')).toBeVisible()
await expect(page).toHaveTitle(/Dashboard/)

// ❌ Bad - doesn't wait, leads to flaky tests
expect(await page.getByText('Welcome').isVisible()).toBe(true)

Auto-Retrying Assertions

Assertion Purpose
toBeVisible() Element is visible
toBeHidden() Element is hidden
toBeEnabled() Element is enabled
toBeDisabled() Element is disabled
toHaveText() Exact text match
toContainText() Partial text match
toHaveValue() Input value
toHaveAttribute() Attribute value
toHaveCount() Number of elements
toHaveURL() Page URL
toHaveTitle() Page title

Soft Assertions

Non-blocking checks:

await expect.soft(page.getByTestId('status')).toHaveText('Success')
await expect.soft(page.getByTestId('count')).toHaveText('5')
// Test continues even if above fail

Anti-Patterns

Manual Waits

// Bad
await page.waitForTimeout(2000)

// Good
await expect(page.getByText('Loaded')).toBeVisible()

Implementation-Tied Selectors

// Bad
await page.locator('div.container > button.btn-primary').click()

// Good
await page.getByRole('button', { name: 'Submit' }).click()

Using first/last/nth Without Reason

// Bad - fragile
await page.getByRole('button').first().click()

// Good - uniquely identify
await page.getByRole('button', { name: 'Submit' }).click()

Ambiguous Text Selectors (Strict Mode Violations)

getByText() matches all elements containing that text, causing strict mode violations when multiple elements match. Common in UIs with tabs, section headers, and settings that share terminology.

// Bad - "Nodes" appears in tab, section header, and setting labels
await expect(panel.getByText('Nodes')).toBeVisible()
// Error: strict mode violation, resolved to 4 elements

// Good - use role with exact name (section headers are often buttons)
await expect(panel.getByRole('button', { name: 'NODES' })).toBeVisible()

// Good - use exact match when appropriate
await expect(panel.getByText('Nodes', { exact: true })).toBeVisible()

// Good - scope to a more specific container first
await expect(panel.locator('[role="tablist"]').getByText('Nodes')).toBeVisible()

Common patterns that cause this:

  • Tabs and section headers with same text (e.g., "Nodes" tab + "NODES" accordion)
  • Settings containing the section name (e.g., "Nodes 2.0", "Snap nodes to grid")
  • Repeated labels across different panels

Non-Awaited Assertions

// Bad
expect(await page.getByText('Hello').isVisible()).toBe(true)

// Good
await expect(page.getByText('Hello')).toBeVisible()

Shared State Between Tests

// Bad
let sharedData
test('first', async () => {
  sharedData = 'value'
})
test('second', async () => {
  expect(sharedData).toBe('value')
})

// Good - each test independent
test('first', async ({ page }) => {
  /* complete setup */
})
test('second', async ({ page }) => {
  /* complete setup */
})

Locator Chaining

// Within a container
const dialog = page.getByRole('dialog')
await dialog.getByRole('button', { name: 'Submit' }).click()

// Filter
const product = page.getByRole('listitem').filter({ hasText: 'Product 2' })
await product.getByRole('button', { name: 'Add' }).click()

Network Mocking

// Mock API
await page.route('**/api/users', (route) =>
  route.fulfill({
    status: 200,
    body: JSON.stringify([{ id: 1, name: 'Test' }])
  })
)

// Block resources
await context.route('**/*.{png,jpg}', (route) => route.abort())

Test Annotations

// Skip conditionally
test('firefox only', async ({ browserName }) => {
  test.skip(browserName !== 'firefox', 'Firefox only')
})

// Slow test (triples timeout)
test('complex', async () => {
  test.slow()
})

// Tag
test('login', { tag: '@smoke' }, async () => {})

Retry Configuration

// Per-describe
test.describe(() => {
  test.describe.configure({ retries: 2 })
  test('flaky', async () => {})
})

// Detect retry
test('cleanup aware', async ({}, testInfo) => {
  if (testInfo.retry) await cleanup()
})

Parallel Execution

// Parallel (default)
test.describe.configure({ mode: 'parallel' })

// Serial (dependent tests)
test.describe.configure({ mode: 'serial' })

Debugging

# Debug mode
npx playwright test --debug

# Trace
npx playwright test --trace on
npx playwright show-report
// Pause in test
await page.pause()

Official Docs