.agents/skills/playwright-best-practices/core/assertions-waiting.md
Auto-retry until condition is met or timeout. Always prefer these over generic assertions.
import {expect} from '@playwright/test'
// Visibility
await expect(page.getByRole('button')).toBeVisible()
await expect(page.getByRole('button')).toBeHidden()
await expect(page.getByRole('button')).not.toBeVisible()
// Enabled/Disabled
await expect(page.getByRole('button')).toBeEnabled()
await expect(page.getByRole('button')).toBeDisabled()
// Text content
await expect(page.getByRole('heading')).toHaveText('Welcome')
await expect(page.getByRole('heading')).toHaveText(/welcome/i)
await expect(page.getByRole('heading')).toContainText('Welcome')
// Count
await expect(page.getByRole('listitem')).toHaveCount(5)
// Attributes
await expect(page.getByRole('link')).toHaveAttribute('href', '/home')
await expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)
// CSS
await expect(page.getByRole('button')).toHaveClass(/primary/)
await expect(page.getByRole('button')).toHaveCSS('color', 'rgb(0, 0, 255)')
// Input values
await expect(page.getByLabel('Email')).toHaveValue('[email protected]')
await expect(page.getByLabel('Email')).toBeEmpty()
// Focus
await expect(page.getByLabel('Email')).toBeFocused()
// Checked state
await expect(page.getByRole('checkbox')).toBeChecked()
await expect(page.getByRole('checkbox')).not.toBeChecked()
// Editable state
await expect(page.getByLabel('Name')).toBeEditable()
// URL
await expect(page).toHaveURL('/dashboard')
await expect(page).toHaveURL(/\/dashboard/)
// Title
await expect(page).toHaveTitle('Dashboard - MyApp')
await expect(page).toHaveTitle(/dashboard/i)
const response = await page.request.get('/api/users')
await expect(response).toBeOK()
await expect(response).not.toBeOK()
Use for non-UI values. Do NOT retry - execute immediately.
// Equality
expect(value).toBe(5)
expect(object).toEqual({name: 'Test'})
expect(array).toContain('item')
// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()
// Numbers
expect(value).toBeGreaterThan(5)
expect(value).toBeLessThanOrEqual(10)
expect(value).toBeCloseTo(5.5, 1)
// Strings
expect(string).toMatch(/pattern/)
expect(string).toContain('substring')
// Arrays/Objects
expect(array).toHaveLength(3)
expect(object).toHaveProperty('key', 'value')
// Exceptions
expect(() => fn()).toThrow()
expect(() => fn()).toThrow('error message')
await expect(asyncFn()).rejects.toThrow()
Continue test execution after failure, report all failures at end.
test('check multiple elements', async ({page}) => {
await page.goto('/dashboard')
// Won't stop on first failure
await expect.soft(page.getByRole('heading')).toHaveText('Dashboard')
await expect.soft(page.getByRole('button', {name: 'Save'})).toBeEnabled()
await expect.soft(page.getByText('Welcome')).toBeVisible()
// Test continues; all failures reported at end
})
test('check form', async ({page}) => {
await expect.soft(page.getByRole('form')).toBeVisible()
// Exit early if form not visible (pointless to check fields)
if (expect.soft.hasFailures()) {
return
}
await expect.soft(page.getByLabel('Name')).toBeVisible()
await expect.soft(page.getByLabel('Email')).toBeVisible()
})
Actions automatically wait for:
// These auto-wait
await page.click('button')
await page.fill('input', 'text')
await page.getByRole('button').click()
// Wait for URL change
await page.waitForURL('/dashboard')
await page.waitForURL(/\/dashboard/)
// Wait for navigation after action
await Promise.all([page.waitForURL('**/dashboard'), page.click('a[href="/dashboard"]')])
// Or without Promise.all
const urlPromise = page.waitForURL('**/dashboard')
await page.click('a')
await urlPromise
// Wait for specific response
const responsePromise = page.waitForResponse('**/api/users')
await page.click('button')
const response = await responsePromise
expect(response.status()).toBe(200)
// Wait for request
const requestPromise = page.waitForRequest('**/api/submit')
await page.click('button')
const request = await requestPromise
// Wait for no network activity
await page.waitForLoadState('networkidle')
// Wait for element to appear
await page.getByRole('dialog').waitFor({state: 'visible'})
// Wait for element to disappear
await page.getByText('Loading...').waitFor({state: 'hidden'})
// Wait for element to be attached
await page.getByTestId('result').waitFor({state: 'attached'})
// Wait for element to be detached
await page.getByTestId('modal').waitFor({state: 'detached'})
// Wait for arbitrary condition
await page.waitForFunction(() => {
return document.querySelector('.loaded') !== null
})
// With arguments
await page.waitForFunction(
(selector) => document.querySelector(selector)?.textContent === 'Ready',
'.status',
)
Retry until block passes or times out:
await expect(async () => {
const response = await page.request.get('/api/status')
expect(response.status()).toBe(200)
const data = await response.json()
expect(data.ready).toBe(true)
}).toPass({
intervals: [1000, 2000, 5000], // Retry intervals
timeout: 30000,
})
Poll a function until assertion passes:
// Poll API until condition met
await expect
.poll(
async () => {
const response = await page.request.get('/api/job/123')
return (await response.json()).status
},
{
intervals: [1000, 2000, 5000],
timeout: 30000,
},
)
.toBe('completed')
// Poll DOM value
await expect.poll(() => page.getByTestId('counter').textContent()).toBe('10')
// playwright.config.ts or fixtures
import {expect} from '@playwright/test'
expect.extend({
async toHaveDataLoaded(page: Page) {
const locator = page.getByTestId('data-container')
let pass = false
let message = ''
try {
await expect(locator).toBeVisible()
await expect(locator).not.toContainText('Loading')
pass = true
} catch (e) {
message = `Expected data to be loaded but found loading state`
}
return {pass, message: () => message}
},
})
// Extend TypeScript types
declare global {
namespace PlaywrightTest {
interface Matchers<R> {
toHaveDataLoaded(): Promise<R>
}
}
}
// Usage
await expect(page).toHaveDataLoaded()
// playwright.config.ts
export default defineConfig({
timeout: 30000, // Test timeout
expect: {
timeout: 5000, // Assertion timeout
},
})
// Per-test timeout
test('long test', async ({page}) => {
test.setTimeout(60000)
// ...
})
// Per-assertion timeout
await expect(page.getByRole('button')).toBeVisible({timeout: 10000})
| Do | Don't |
|---|---|
| Use web-first assertions | Use generic assertions for DOM |
| Let auto-waiting work | Add unnecessary explicit waits |
Use toPass() for polling | Write manual retry loops |
| Configure appropriate timeouts | Use waitForTimeout() |
| Check specific conditions | Wait for arbitrary time |
| Anti-Pattern | Problem | Solution |
|---|---|---|
await page.waitForTimeout(5000) | Slow, flaky, arbitrary timing | Use auto-waiting or waitForResponse |
await new Promise(resolve => setTimeout(resolve, 1000)) | Same as above | Use waitForResponse or element state waits |
| Generic assertions on DOM elements | No auto-retry, flaky | Use web-first assertions with expect() |