| name | Playwright Testing |
| description | End-to-end testing with Playwright. Cross-browser testing, visual regression, API testing, and component testing. Use for E2E tests in TypeScript/JavaScript and Python projects. |
| allowed-tools | Bash, Read, Edit, Write, Grep, Glob, TodoWrite |
Playwright Testing
Expert knowledge for end-to-end testing with Playwright - a modern cross-browser testing framework with powerful debugging and reliable automation.
Core Expertise
Playwright Advantages
- Cross-browser: Chromium, Firefox, WebKit (Safari) support
- Auto-wait: Built-in smart waiting, no flaky tests
- Powerful selectors: CSS, text, role-based selectors
- Network control: Mock APIs, intercept requests
- Visual testing: Screenshots, videos, trace viewer
- Parallel execution: Fast test runs across browsers
- TypeScript-first: Excellent type safety and autocomplete
Quick Start
Installation (TypeScript/JavaScript)
# Using Bun
bun add -d @playwright/test
# Using npm
npm init playwright@latest
# Using pnpm
pnpm create playwright
Installation (Python)
# Using uv
uv add --dev pytest-playwright
uv run playwright install
# Using pip
pip install pytest-playwright
playwright install
Basic Configuration (TypeScript)
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile browsers
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'bun run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
Basic Configuration (Python)
# conftest.py
import pytest
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"viewport": {"width": 1920, "height": 1080},
"base_url": "http://localhost:3000",
}
Running Tests
TypeScript Commands
# Run all tests
npx playwright test
# Run in headed mode (see browser)
npx playwright test --headed
# Run specific test file
npx playwright test tests/login.spec.ts
# Run tests matching pattern
npx playwright test --grep="login"
# Run in debug mode
npx playwright test --debug
# Run with UI mode (interactive)
npx playwright test --ui
# Run specific browser
npx playwright test --project=chromium
npx playwright test --project=firefox
# Generate test report
npx playwright show-report
# Open trace viewer for failed tests
npx playwright show-trace trace.zip
Python Commands
# Run all tests
uv run pytest
# Run with headed browser
uv run pytest --headed
# Run specific test
uv run pytest tests/test_login.py
# Run with specific browser
uv run pytest --browser chromium
uv run pytest --browser firefox
uv run pytest --browser webkit
# Slow motion (for debugging)
uv run pytest --slowmo 1000
# Generate video
uv run pytest --video on
Writing Tests (TypeScript)
Basic Test Structure
import { test, expect } from '@playwright/test'
test.describe('Login flow', () => {
test('successful login', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Login' }).click()
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('wrong@example.com')
await page.getByLabel('Password').fill('wrongpassword')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByText('Invalid credentials')).toBeVisible()
})
})
Test Hooks
import { test, expect } from '@playwright/test'
test.describe('User management', () => {
test.beforeAll(async () => {
// Setup database
})
test.beforeEach(async ({ page }) => {
await page.goto('/admin/users')
// Setup per test
})
test.afterEach(async ({ page }) => {
// Cleanup per test
})
test.afterAll(async () => {
// Teardown database
})
})
Writing Tests (Python)
Basic Test Structure
import pytest
from playwright.sync_api import Page, expect
def test_successful_login(page: Page):
page.goto("/")
page.get_by_role("link", name="Login").click()
page.get_by_label("Email").fill("user@example.com")
page.get_by_label("Password").fill("password123")
page.get_by_role("button", name="Sign in").click()
expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()
def test_invalid_credentials(page: Page):
page.goto("/login")
page.get_by_label("Email").fill("wrong@example.com")
page.get_by_label("Password").fill("wrongpassword")
page.get_by_role("button", name="Sign in").click()
expect(page.get_by_text("Invalid credentials")).to_be_visible()
Test Fixtures (Python)
import pytest
from playwright.sync_api import Page
@pytest.fixture
def authenticated_page(page: Page):
page.goto("/login")
page.get_by_label("Email").fill("user@example.com")
page.get_by_label("Password").fill("password123")
page.get_by_role("button", name="Sign in").click()
return page
def test_dashboard_access(authenticated_page: Page):
authenticated_page.goto("/dashboard")
expect(authenticated_page.get_by_role("heading", name="Dashboard")).to_be_visible()
Selectors
Role-Based Selectors (Recommended)
// Most reliable - based on ARIA roles
await page.getByRole('button', { name: 'Submit' })
await page.getByRole('link', { name: 'Home' })
await page.getByRole('textbox', { name: 'Email' })
await page.getByRole('checkbox', { name: 'Remember me' })
await page.getByRole('heading', { name: 'Welcome', level: 1 })
Text Selectors
// Find by exact text
await page.getByText('Hello World')
// Find by partial text
await page.getByText('Hello', { exact: false })
// Case insensitive
await page.getByText(/hello world/i)
Label Selectors
// Find inputs by label
await page.getByLabel('Email')
await page.getByLabel('Password')
await page.getByLabel(/email/i)
Placeholder and Alt Text
await page.getByPlaceholder('Enter email')
await page.getByAltText('Company logo')
Test ID (Fallback)
// In HTML: <div data-testid="submit-button">
await page.getByTestId('submit-button')
CSS Selectors (Use Sparingly)
await page.locator('.btn-primary')
await page.locator('#login-form')
await page.locator('button.submit')
Combining Selectors
// Chaining
await page.getByRole('navigation').getByRole('link', { name: 'Home' })
// Filtering
await page.getByRole('listitem').filter({ hasText: 'Product 1' })
// First/Last/Nth
await page.getByRole('button').first()
await page.getByRole('button').last()
await page.getByRole('button').nth(2)
Assertions
Visibility Assertions
await expect(page.getByText('Success')).toBeVisible()
await expect(page.getByText('Loading')).toBeHidden()
await expect(page.getByRole('button')).toBeEnabled()
await expect(page.getByRole('button')).toBeDisabled()
Text Assertions
await expect(page.getByRole('heading')).toHaveText('Welcome')
await expect(page.getByRole('heading')).toContainText('Welcome')
await expect(page.getByRole('alert')).toHaveText(/error/i)
Attribute Assertions
await expect(page.getByRole('link')).toHaveAttribute('href', '/home')
await expect(page.getByRole('img')).toHaveAttribute('alt', 'Logo')
await expect(page.getByRole('button')).toHaveClass('btn-primary')
Value Assertions
await expect(page.getByLabel('Email')).toHaveValue('user@example.com')
await expect(page.getByLabel('Count')).toHaveValue('5')
URL Assertions
await expect(page).toHaveURL('/dashboard')
await expect(page).toHaveURL(/\/dashboard/)
await expect(page).toHaveTitle('Dashboard')
Count Assertions
await expect(page.getByRole('listitem')).toHaveCount(5)
Actions
Clicking
// Basic click
await page.getByRole('button', { name: 'Submit' }).click()
// Double click
await page.getByText('File').dblclick()
// Right click
await page.getByText('Item').click({ button: 'right' })
// Click with modifiers
await page.getByRole('link').click({ modifiers: ['Control'] })
// Force click (bypass visibility checks)
await page.getByRole('button').click({ force: true })
Typing
// Fill (clears then types)
await page.getByLabel('Email').fill('user@example.com')
// Type (appends)
await page.getByLabel('Search').type('query')
// Press keys
await page.getByLabel('Search').press('Enter')
await page.keyboard.press('Control+A')
// Type slowly (for animations)
await page.getByLabel('Search').type('query', { delay: 100 })
Selecting
// Select by value
await page.getByLabel('Country').selectOption('us')
// Select by label
await page.getByLabel('Country').selectOption({ label: 'United States' })
// Select multiple
await page.locator('select[multiple]').selectOption(['us', 'uk', 'ca'])
File Upload
// Single file
await page.getByLabel('Upload').setInputFiles('path/to/file.pdf')
// Multiple files
await page.getByLabel('Upload').setInputFiles([
'file1.pdf',
'file2.pdf',
])
// Remove files
await page.getByLabel('Upload').setInputFiles([])
Drag and Drop
await page.getByText('Item').dragTo(page.getByText('Target'))
Network Mocking
Intercept API Requests
import { test, expect } from '@playwright/test'
test('mocks API response', async ({ page }) => {
// Mock API endpoint
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Test User' },
]),
})
})
await page.goto('/users')
await expect(page.getByText('Test User')).toBeVisible()
})
Intercept and Modify
test('modifies API response', async ({ page }) => {
await page.route('**/api/config', async route => {
const response = await route.fetch()
const json = await response.json()
json.feature_flag = true
await route.fulfill({ json })
})
await page.goto('/')
})
Wait for Requests
test('waits for API call', async ({ page }) => {
await page.goto('/')
// Wait for specific request
const response = await page.waitForResponse('**/api/users')
expect(response.status()).toBe(200)
// Wait for multiple requests
const [response1, response2] = await Promise.all([
page.waitForResponse('**/api/users'),
page.waitForResponse('**/api/posts'),
page.getByRole('button', { name: 'Load' }).click(),
])
})
Visual Testing
Screenshots
import { test } from '@playwright/test'
test('captures screenshot', async ({ page }) => {
await page.goto('/')
// Full page screenshot
await page.screenshot({ path: 'screenshot.png', fullPage: true })
// Element screenshot
await page.getByRole('navigation').screenshot({ path: 'nav.png' })
// Compare screenshot
await expect(page).toHaveScreenshot('homepage.png')
})
Video Recording
// In playwright.config.ts
use: {
video: 'on', // or 'retain-on-failure', 'off'
videoSize: { width: 1280, height: 720 }
}
Trace Viewer
// In playwright.config.ts
use: {
trace: 'on-first-retry', // or 'on', 'off', 'retain-on-failure'
}
# View trace
npx playwright show-trace trace.zip
Authentication & State
Save Authentication State
import { test as setup } from '@playwright/test'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
// Save storage state
await page.context().storageState({ path: 'auth.json' })
})
Reuse Authentication State
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'auth.json',
},
dependencies: ['setup'],
},
],
})
Best Practices
Selector Strategy
- Prefer role-based selectors:
getByRole('button', { name: 'Submit' }) - Use labels for inputs:
getByLabel('Email') - Text for content:
getByText('Welcome') - Test IDs as fallback:
getByTestId('submit-btn') - Avoid CSS selectors: They break easily with styling changes
Auto-Waiting
- Playwright automatically waits for elements
- No need for manual
sleep()orwaitFor() - Trust the built-in waiting mechanisms
Isolation
- Each test gets a fresh browser context
- No state pollution between tests
- Clean cookies, storage, cache per test
Debugging
# Debug mode (step through tests)
npx playwright test --debug
# Headed mode (see browser)
npx playwright test --headed --slowmo 1000
# UI mode (interactive explorer)
npx playwright test --ui
# Trace viewer (time-travel debugging)
npx playwright show-trace trace.zip
Performance
- Run tests in parallel (default)
- Use
fullyParallel: truein config - Limit workers in CI:
workers: 1 - Reuse browser contexts when possible
Flaky Tests
- Use auto-waiting, avoid manual waits
- Use specific selectors, avoid brittle CSS
- Mock network requests for consistency
- Set proper timeouts for slow operations
Test Data
- Reset database state before tests
- Use unique test data per test
- Clean up after tests
- Mock external dependencies
Common Patterns
Page Object Model
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.submitButton = page.getByRole('button', { name: 'Sign in' })
}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
// Usage in test
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
test('login flow', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'password123')
await expect(page).toHaveURL('/dashboard')
})
API Testing
import { test, expect } from '@playwright/test'
test('API test', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'John Doe',
email: 'john@example.com',
},
})
expect(response.ok()).toBeTruthy()
expect(response.status()).toBe(201)
const json = await response.json()
expect(json.id).toBeDefined()
expect(json.name).toBe('John Doe')
})
Mobile Testing
// playwright.config.ts
import { devices } from '@playwright/test'
export default defineConfig({
projects: [
{
name: 'iPhone 13',
use: { ...devices['iPhone 13'] },
},
{
name: 'iPad Pro',
use: { ...devices['iPad Pro'] },
},
],
})
Troubleshooting
Element not found
// Wait for element explicitly
await page.getByRole('button').waitFor()
// Check if element exists
const isVisible = await page.getByRole('button').isVisible()
Timeout errors
// Increase timeout for specific action
await page.getByRole('button').click({ timeout: 10000 })
// Global timeout in config
use: {
actionTimeout: 10000,
navigationTimeout: 30000,
}
Flaky tests
// Use auto-retry assertions
await expect(page.getByText('Success')).toBeVisible({ timeout: 5000 })
// Retry failed tests
retries: process.env.CI ? 2 : 0
See Also
vitest-testing- Unit and integration testingtest-quality-analysis- Detecting test smellsnodejs-development- TypeScript project setuppython-testing- Python pytest integration
References
- Official docs: https://playwright.dev/
- API reference: https://playwright.dev/docs/api/class-playwright
- Best practices: https://playwright.dev/docs/best-practices