| name | playwright |
| description | Playwright for end-to-end testing, browser automation, and web scraping. Use when user mentions "playwright", "e2e testing", "end to end test", "browser testing", "browser automation", "web scraping", "headless browser", "cross-browser testing", "page.goto", "locator", or automating browser interactions. |
Playwright
Setup
npm init playwright@latest
npx playwright install
npx playwright install chromium
Configuration
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'], ['list']],
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'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Writing Tests
import { test, expect } from '@playwright/test';
test.describe('Feature Name', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should do something specific', async ({ page }) => {
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Success')).toBeVisible();
});
});
Playwright auto-waits for elements to be visible, stable, enabled, and receiving events before performing actions. Assertions auto-retry until the timeout (default 5s).
Locators
Prefer role-based and user-facing locators over CSS selectors.
page.getByRole('button', { name: 'Sign In' })
page.getByRole('heading', { name: 'Dashboard', level: 2 })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('checkbox', { name: 'Remember me' })
page.getByText('Welcome back')
page.getByLabel('Email address')
page.getByPlaceholder('Enter your email')
page.getByTestId('submit-button')
page.locator('.nav-item.active')
page.locator('xpath=//div[@class="container"]//span')
page.getByRole('listitem').filter({ hasText: 'Product A' })
page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'Buy' }) })
page.locator().()
page.().()
Actions
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button').dblclick();
await page.getByRole('button').click({ button: 'right' });
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Name').pressSequentially('John', { delay: 50 });
await page.getByLabel('Name').clear();
await page.keyboard.press('Enter');
await page.keyboard.press('Control+A');
await page.getByLabel('Country').selectOption('us');
await page.().({ : });
page.(, { : }).();
page.(, { : }).();
page.().();
page.().();
page.().(page.());
Assertions
await expect(page.getByText('Welcome')).toBeVisible();
await expect(page.getByText('Loading')).toBeHidden();
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.getByRole('heading')).toHaveText(/dashboard/i);
await expect(page.getByRole('status')).toContainText('3 items');
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('My App - Dashboard');
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole()).();
(page.()).();
(page.()).();
(page.()).(, );
(page.())..();
expect.(page.()).();
Page Navigation and Waiting
await page.goto('https://example.com');
await page.goto('/relative-path');
await page.goBack();
await page.reload();
await page.waitForURL('**/dashboard');
await page.waitForSelector('.dynamic-content', { state: 'visible' });
await page.waitForSelector('.spinner', { state: 'detached' });
const response = await page.waitForResponse(
resp => resp.url().includes('/api/users') && resp.status() === 200
);
await page.waitForLoadState('networkidle');
await page.waitForFunction(() => document.title.includes('Ready'));
await page.getByText('Loaded').click({ timeout: 10000 });
Network Interception
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Alice' }]),
});
});
await page.route('**/api/settings', async route => {
const response = await route.fetch();
const json = await response.json();
json.featureFlag = true;
await route.fulfill({ response, json });
});
await page.route('**/*.{png,jpg,jpeg,gif,svg}', route => route.abort());
await page.route('**/api/submit', async route => {
const postData = route.request().postDataJSON();
expect(postData.email).();
route.();
});
page.(, { : });
page.();
Authentication
Save login state once, reuse across all tests:
import { test as setup } 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();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: authFile });
});
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
},
Add playwright/.auth/ to .gitignore.
Multiple Pages, Tabs, and Popups
const newPage = await page.context().newPage();
await newPage.goto('/another-page');
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
const popup = await popupPromise;
await popup.waitForLoadState();
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
Screenshots and Video
await page.screenshot({ path: 'screenshots/home.png' });
await page.screenshot({ path: 'full.png', fullPage: true });
await page.getByTestId('chart').screenshot({ path: 'chart.png' });
Configure globally in playwright.config.ts under use: screenshot: 'only-on-failure', video: 'retain-on-failure'.
Visual Regression Testing
await expect(page).toHaveScreenshot();
await expect(page).toHaveScreenshot('homepage.png');
await expect(page).toHaveScreenshot({ maxDiffPixels: 100 });
await expect(page.getByTestId('header')).toHaveScreenshot('header.png');
Update baselines with npx playwright test --update-snapshots. Baselines are stored alongside the test file in a -snapshots/ directory. Commit them to version control.
Parallel Execution and Sharding
export default defineConfig({
fullyParallel: true,
workers: 4,
});
test.describe.configure({ mode: 'serial' });
Shard across CI machines: npx playwright test --shard=1/3, --shard=2/3, --shard=3/3.
Debugging
npx playwright test --headed
npx playwright test --debug
npx playwright codegen https://example.com
npx playwright show-trace trace.zip
npx playwright test --grep "login"
npx playwright test tests/login.spec.ts
Use await page.pause() inside a test to pause execution and open Inspector.
Trace viewer shows a timeline of actions, DOM snapshots at each step, network requests, and console logs. Enable with trace: 'on-first-retry' in config or record manually:
await page.context().tracing.start({ screenshots: true, snapshots: true });
await page.context().tracing.stop({ path: 'trace.zip' });
Page Object Model
import { type Locator, type Page, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(private 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) {
..(email);
..(password);
..();
}
}
(, ({ page }) => {
loginPage = (page);
loginPage.();
loginPage.(, );
(page).();
});
CI/CD Integration
GitHub Actions
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: { name: playwright-report, path: playwright-report/, retention-days: 30 }
Sharded CI
jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}
Docker
FROM mcr.microsoft.com/playwright:v1.48.0-noble
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx playwright test
Common Patterns
File Upload and Download
await page.getByLabel('Upload').setInputFiles('tests/fixtures/doc.pdf');
await page.getByLabel('Upload').setInputFiles(['a.png', 'b.png']);
await page.getByLabel('Upload').setInputFiles([]);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download Report' }).click();
const download = await downloadPromise;
await download.saveAs('downloads/' + download.suggestedFilename());
Iframe Handling
const frame = page.frameLocator('#my-iframe');
await frame.getByRole('button', { name: 'Click me' }).click();
await expect(frame.getByText('Done')).toBeVisible();
Dialog Handling
page.on('dialog', dialog => dialog.accept());
page.on('dialog', dialog => dialog.dismiss());
page.once('dialog', async dialog => {
expect(dialog.message()).toBe('Are you sure?');
await dialog.accept();
});
await page.getByRole('button', { name: 'Delete' }).click();
Waiting for API Before Asserting
const responsePromise = page.waitForResponse('**/api/save');
await page.getByRole('button', { name: 'Save' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(page.getByText('Saved')).toBeVisible();
Form Validation Testing
test('validates required fields', async ({ page }) => {
await page.goto('/contact');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Email is required')).toBeVisible();
await page.getByLabel('Email').fill('invalid');
await expect(page.getByText('Invalid email format')).toBeVisible();
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Message').fill('Hello');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Message sent')).toBeVisible();
});