Playwright - Complete guide to E2E testing
What is Playwright?
Playwright is a modern browser automation and end-to-end testing framework created by Microsoft. It supports all major browsers (Chromium, Firefox, WebKit) with a single consistent API, offering fast, reliable, and cross-browser testing of web applications.
Unlike older tools such as Selenium, Playwright was designed from the ground up with modern applications in mind - it handles auto-waiting, web components, shadow DOM, iframes, and many other challenging scenarios without additional configuration.
Why Playwright?
Key advantages
- Cross-browser - Chromium, Firefox, WebKit with a single API
- Auto-waiting - Automatic waiting for elements
- Speed - Parallel test execution
- Reliability - No flaky tests thanks to smart retries
- Codegen - Recording tests through interaction
- Trace Viewer - Debugging with full context
- API Testing - Built-in REST API testing
Playwright vs Cypress vs Selenium
| Feature | Playwright | Cypress | Selenium |
|---|---|---|---|
| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox (beta) | All |
| Speed | Very fast | Fast | Slow |
| Auto-waiting | Built-in | Built-in | Manual |
| Parallel | Native | Paid (Cloud) | Requires Grid |
| iframes | Full support | Limited | Full |
| Mobile emulation | Yes | No | Via Appium |
| API testing | Built-in | Plugin | External |
| Languages | JS/TS, Python, .NET, Java | JS/TS only | All |
Installation and configuration
New project
# Initialize a Playwright project
npm init playwright@latest
# Answer the prompts:
# - TypeScript or JavaScript
# - Test folder (tests/)
# - Add GitHub Actions workflow?
# - Install browsers?Project structure
my-project/
├── tests/
│ ├── example.spec.ts
│ └── auth.spec.ts
├── playwright.config.ts
├── package.json
└── .github/
└── workflows/
└── playwright.ymlConfiguring playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
// Test directory
testDir: './tests',
// Run tests in parallel
fullyParallel: true,
// Fail build on CI if you accidentally left test.only
forbidOnly: !!process.env.CI,
// Retry failed tests on CI
retries: process.env.CI ? 2 : 0,
// Workers - parallel processes
workers: process.env.CI ? 1 : undefined,
// Reporter
reporter: [
['html'],
['list'],
process.env.CI ? ['github'] : ['line']
],
// Global test settings
use: {
// Base URL
baseURL: 'http://localhost:3000',
// Trace on failure
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
// Video on failure
video: 'on-first-retry',
},
// Projects = browser configurations
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
// Start dev server before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})Writing tests
Basic structure
import { test, expect } from '@playwright/test'
test.describe('Homepage', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(/My App/)
})
test('navigation works', async ({ page }) => {
await page.getByRole('link', { name: 'About' }).click()
await expect(page).toHaveURL('/about')
})
})Locators - finding elements
import { test, expect } from '@playwright/test'
test('locators examples', async ({ page }) => {
await page.goto('/form')
// ✅ Recommended - semantic locators
// Role-based (accessibility)
await page.getByRole('button', { name: 'Submit' }).click()
await page.getByRole('link', { name: 'Home' }).click()
await page.getByRole('heading', { name: 'Welcome' })
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')
await page.getByRole('checkbox', { name: 'Accept terms' }).check()
// Label-based
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Password').fill('secret123')
// Placeholder
await page.getByPlaceholder('Enter your email').fill('test@example.com')
// Text content
await page.getByText('Welcome back')
await page.getByText(/welcome/i) // Regex, case-insensitive
// Test ID (for complex cases)
await page.getByTestId('submit-button').click()
// ⚠️ Fallback - CSS/XPath (less stable)
await page.locator('.submit-btn').click()
await page.locator('#email-input').fill('test@example.com')
await page.locator('button[type="submit"]').click()
await page.locator('//button[text()="Submit"]').click() // XPath
})Chaining locators
test('chained locators', async ({ page }) => {
// Find an element within the context of another
const form = page.locator('form#login')
await form.getByLabel('Email').fill('test@example.com')
await form.getByLabel('Password').fill('secret')
await form.getByRole('button', { name: 'Login' }).click()
// Filtering
const rows = page.getByRole('row')
const activeRow = rows.filter({ hasText: 'Active' })
await activeRow.getByRole('button', { name: 'Edit' }).click()
// Nth element
await page.getByRole('listitem').nth(2).click() // Third element
await page.getByRole('listitem').first().click()
await page.getByRole('listitem').last().click()
})Assertions
import { test, expect } from '@playwright/test'
test('assertions examples', async ({ page }) => {
await page.goto('/dashboard')
// Page assertions
await expect(page).toHaveTitle('Dashboard')
await expect(page).toHaveURL(/dashboard/)
await expect(page).toHaveURL('http://localhost:3000/dashboard')
// Element visibility
await expect(page.getByText('Welcome')).toBeVisible()
await expect(page.getByText('Error')).toBeHidden()
await expect(page.getByTestId('loading')).not.toBeVisible()
// Element state
await expect(page.getByRole('button')).toBeEnabled()
await expect(page.getByRole('button')).toBeDisabled()
await expect(page.getByRole('checkbox')).toBeChecked()
await expect(page.getByRole('textbox')).toBeEditable()
await expect(page.getByRole('textbox')).toBeFocused()
// Element content
await expect(page.getByRole('heading')).toHaveText('Dashboard')
await expect(page.getByRole('heading')).toContainText('Dash')
await expect(page.getByRole('textbox')).toHaveValue('initial value')
await expect(page.getByRole('textbox')).toBeEmpty()
// Element attributes
await expect(page.getByRole('link')).toHaveAttribute('href', '/about')
await expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)
// CSS
await expect(page.getByTestId('box')).toHaveCSS('color', 'rgb(0, 0, 0)')
await expect(page.getByRole('button')).toHaveClass(/primary/)
// Count
await expect(page.getByRole('listitem')).toHaveCount(5)
// Soft assertions (do not stop the test)
await expect.soft(page.getByText('Optional')).toBeVisible()
})Interactions
test('interactions', async ({ page }) => {
await page.goto('/form')
// Clicking
await page.getByRole('button').click()
await page.getByRole('button').click({ button: 'right' }) // Right click
await page.getByRole('button').dblclick() // Double click
await page.getByRole('button').click({ modifiers: ['Control'] }) // Ctrl+click
// Hover
await page.getByText('Menu').hover()
// Filling forms
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Email').clear()
await page.getByLabel('Email').type('test@example.com') // Keystroke by keystroke
// Press keys
await page.getByLabel('Search').press('Enter')
await page.keyboard.press('Escape')
await page.keyboard.press('Control+A')
// Select dropdown
await page.getByLabel('Country').selectOption('poland')
await page.getByLabel('Country').selectOption({ label: 'Poland' })
await page.getByLabel('Multi').selectOption(['opt1', 'opt2'])
// Checkbox & radio
await page.getByLabel('Accept').check()
await page.getByLabel('Accept').uncheck()
await page.getByLabel('Accept').setChecked(true)
// File upload
await page.getByLabel('Upload').setInputFiles('path/to/file.pdf')
await page.getByLabel('Upload').setInputFiles(['file1.pdf', 'file2.pdf'])
// Drag and drop
await page.getByTestId('source').dragTo(page.getByTestId('target'))
// Focus
await page.getByLabel('Email').focus()
await page.getByLabel('Email').blur()
})Waiting and timeouts
test('waiting', async ({ page }) => {
await page.goto('/dashboard')
// Auto-waiting - Playwright automatically waits for elements
// The following by itself waits until the element is visible and clickable
await page.getByRole('button').click()
// Explicit waits
await page.waitForSelector('.loaded')
await page.waitForLoadState('networkidle')
await page.waitForLoadState('domcontentloaded')
await page.waitForURL('/dashboard')
// Wait for response
const responsePromise = page.waitForResponse('**/api/data')
await page.getByRole('button', { name: 'Load' }).click()
const response = await responsePromise
// Wait for request
const requestPromise = page.waitForRequest('**/api/submit')
await page.getByRole('button', { name: 'Submit' }).click()
await requestPromise
// Custom wait
await page.waitForFunction(() => {
return document.querySelector('.counter')?.textContent === '10'
})
// Timeout override
await page.getByRole('button').click({ timeout: 10000 })
// Poll until condition
await expect(async () => {
const count = await page.getByTestId('count').textContent()
expect(parseInt(count!)).toBeGreaterThan(5)
}).toPass({ timeout: 10000 })
})Advanced scenarios
Authentication
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
const authFile = 'playwright/.auth/user.json'
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()
// Wait for redirect after login
await page.waitForURL('/dashboard')
// Save authentication state
await page.context().storageState({ path: authFile })
})// playwright.config.ts
export default defineConfig({
projects: [
// Setup project
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
// Tests that need auth
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
})// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test'
// This test uses the logged-in state
test('dashboard shows user data', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByText('Welcome, User')).toBeVisible()
})Multiple contexts (multi-user)
test('admin and user interaction', async ({ browser }) => {
// Admin context
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/admin.json'
})
const adminPage = await adminContext.newPage()
// User context
const userContext = await browser.newContext({
storageState: 'playwright/.auth/user.json'
})
const userPage = await userContext.newPage()
// Admin creates something
await adminPage.goto('/admin/posts')
await adminPage.getByRole('button', { name: 'New Post' }).click()
await adminPage.getByLabel('Title').fill('Test Post')
await adminPage.getByRole('button', { name: 'Publish' }).click()
// User sees it
await userPage.goto('/posts')
await expect(userPage.getByText('Test Post')).toBeVisible()
// Cleanup
await adminContext.close()
await userContext.close()
})API Testing
import { test, expect } from '@playwright/test'
test.describe('API Tests', () => {
test('GET /api/users', async ({ request }) => {
const response = await request.get('/api/users')
expect(response.ok()).toBeTruthy()
expect(response.status()).toBe(200)
const users = await response.json()
expect(users).toHaveLength(10)
expect(users[0]).toHaveProperty('email')
})
test('POST /api/users', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'John Doe',
email: 'john@example.com'
}
})
expect(response.status()).toBe(201)
const user = await response.json()
expect(user.name).toBe('John Doe')
})
test('with authentication', async ({ request }) => {
const response = await request.get('/api/me', {
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`
}
})
expect(response.ok()).toBeTruthy()
})
})Visual regression testing
import { test, expect } from '@playwright/test'
test('visual regression', async ({ page }) => {
await page.goto('/dashboard')
// Full page screenshot
await expect(page).toHaveScreenshot('dashboard.png')
// Element screenshot
const chart = page.getByTestId('revenue-chart')
await expect(chart).toHaveScreenshot('revenue-chart.png')
// With options
await expect(page).toHaveScreenshot('dashboard-full.png', {
fullPage: true,
maxDiffPixels: 100,
threshold: 0.2,
})
})# Update baseline screenshots
npx playwright test --update-snapshotsMocking and intercepting
test('mock API responses', async ({ page }) => {
// Mock a specific endpoint
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Mock User' }
])
})
})
await page.goto('/users')
await expect(page.getByText('Mock User')).toBeVisible()
})
test('modify response', async ({ page }) => {
await page.route('**/api/products', async route => {
const response = await route.fetch()
const json = await response.json()
// Modify response
json.products = json.products.slice(0, 3)
await route.fulfill({ response, json })
})
await page.goto('/products')
})
test('abort requests', async ({ page }) => {
// Block images
await page.route('**/*.{png,jpg,jpeg}', route => route.abort())
// Block analytics
await page.route('**/analytics/**', route => route.abort())
await page.goto('/page')
})
test('delay response', async ({ page }) => {
await page.route('**/api/slow', async route => {
await new Promise(resolve => setTimeout(resolve, 3000))
await route.continue()
})
await page.goto('/page')
// Test loading state
await expect(page.getByText('Loading...')).toBeVisible()
})Fixtures (custom setup)
// fixtures.ts
import { test as base, expect } from '@playwright/test'
// Extend base test with custom fixtures
export const test = base.extend<{
adminPage: Page
userPage: Page
todoApp: TodoApp
}>({
// Admin page fixture
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/admin.json'
})
const page = await context.newPage()
await use(page)
await context.close()
},
// User page fixture
userPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json'
})
const page = await context.newPage()
await use(page)
await context.close()
},
// Page Object fixture
todoApp: async ({ page }, use) => {
const todoApp = new TodoApp(page)
await todoApp.goto()
await use(todoApp)
},
})
export { expect }
// Page Object Model
class TodoApp {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/todos')
}
async addTodo(text: string) {
await this.page.getByPlaceholder('What needs to be done?').fill(text)
await this.page.keyboard.press('Enter')
}
async toggleTodo(text: string) {
await this.page.getByRole('listitem')
.filter({ hasText: text })
.getByRole('checkbox')
.click()
}
async deleteTodo(text: string) {
await this.page.getByRole('listitem')
.filter({ hasText: text })
.hover()
await this.page.getByRole('button', { name: 'Delete' }).click()
}
async getTodoCount() {
return await this.page.getByRole('listitem').count()
}
}// tests/todo.spec.ts
import { test, expect } from '../fixtures'
test('can add and complete todos', async ({ todoApp }) => {
await todoApp.addTodo('Buy groceries')
await todoApp.addTodo('Do laundry')
expect(await todoApp.getTodoCount()).toBe(2)
await todoApp.toggleTodo('Buy groceries')
await todoApp.deleteTodo('Do laundry')
expect(await todoApp.getTodoCount()).toBe(1)
})Running tests
Basic commands
# Run all tests
npx playwright test
# Specific file
npx playwright test tests/login.spec.ts
# Specific test (grep)
npx playwright test -g "login works"
# Specific browser
npx playwright test --project=chromium
npx playwright test --project=firefox
# Headed mode (visible browser)
npx playwright test --headed
# Debug mode
npx playwright test --debug
# UI mode (interactive)
npx playwright test --ui
# Only failed tests
npx playwright test --last-failed
# Repeat test X times
npx playwright test --repeat-each=3Reports
# HTML report
npx playwright show-report
# Trace viewer
npx playwright show-trace trace.zipCodegen - recording tests
# Record interactions and generate code
npx playwright codegen https://example.com
# With device emulation
npx playwright codegen --device="iPhone 13"
# With viewport
npx playwright codegen --viewport-size=800,600
# Save to file
npx playwright codegen -o tests/recorded.spec.tsCI/CD Integration
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30Sharding (parallel CI)
jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- name: Run tests
run: npx playwright test --shard=${{ matrix.shard }}Best practices
Stable selectors
// ✅ Good - semantic, stable
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email')
page.getByTestId('login-form')
// ❌ Avoid - brittle, implementation-dependent
page.locator('.btn-primary')
page.locator('#submit-btn')
page.locator('div > button:nth-child(2)')Test isolation
// ✅ Each test should be independent
test.beforeEach(async ({ page }) => {
// Reset state before each test
await page.goto('/')
})
// ❌ Avoid dependencies between tests
// Test A should not rely on state from Test BTest ID for difficult elements
<!-- In the application -->
<button data-testid="checkout-button">Complete Purchase</button>// In the test
await page.getByTestId('checkout-button').click()FAQ - Frequently asked questions
How to debug failed tests?
- Run with
--debug:npx playwright test --debug - Use Trace Viewer to analyze trace.zip
- Add
await page.pause()in the test code - Use UI mode:
npx playwright test --ui
How to test responsiveness?
test('mobile layout', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/')
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible()
})How to handle a popup/dialog?
page.on('dialog', dialog => dialog.accept())
await page.getByRole('button', { name: 'Delete' }).click()How to test a new window/tab?
const [newPage] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('link', { name: 'Open in new tab' }).click()
])
await expect(newPage).toHaveURL('/new-page')Summary
Playwright is a powerful E2E testing tool offering:
- Cross-browser - One test, all browsers
- Auto-waiting - No more flaky tests
- Speed - Parallel execution
- Debugging - Trace viewer, UI mode, codegen
- Flexibility - API testing, visual testing, mocking
Ideal for teams looking for reliable, fast E2E tests.