Learn-skills.dev playwright

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.

install
source · Clone the upstream repo
git clone https://github.com/NeverSight/learn-skills.dev
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/1mangesh1/dev-skills-collection/playwright" ~/.claude/skills/neversight-learn-skills-dev-playwright && rm -rf "$T"
manifest: data/skills-md/1mangesh1/dev-skills-collection/playwright/SKILL.md
source content

Playwright

Setup

npm init playwright@latest          # scaffolds project with config, sample test, browsers
npx playwright install              # install all browsers
npx playwright install chromium     # install single browser

Configuration

// 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'], ['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.

// Role-based (best for accessibility)
page.getByRole('button', { name: 'Sign In' })
page.getByRole('heading', { name: 'Dashboard', level: 2 })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('checkbox', { name: 'Remember me' })

// Text and label-based
page.getByText('Welcome back')
page.getByLabel('Email address')
page.getByPlaceholder('Enter your email')
page.getByTestId('submit-button')       // matches [data-testid="submit-button"]

// CSS and XPath (last resort)
page.locator('.nav-item.active')
page.locator('xpath=//div[@class="container"]//span')

// Filtering and chaining
page.getByRole('listitem').filter({ hasText: 'Product A' })
page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'Buy' }) })
page.locator('.card').nth(2)
page.locator('.card').first()

Actions

// Click variants
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button').dblclick();
await page.getByRole('button').click({ button: 'right' });

// Text input
await page.getByLabel('Email').fill('user@example.com');       // clears then sets value
await page.getByLabel('Name').pressSequentially('John', { delay: 50 }); // simulates typing
await page.getByLabel('Name').clear();

// Keyboard
await page.keyboard.press('Enter');
await page.keyboard.press('Control+A');

// Select, checkbox, radio
await page.getByLabel('Country').selectOption('us');
await page.getByLabel('Country').selectOption({ label: 'United States' });
await page.getByRole('checkbox', { name: 'Agree' }).check();
await page.getByRole('checkbox', { name: 'Agree' }).uncheck();

// Hover, focus, drag
await page.getByText('Menu').hover();
await page.getByLabel('Email').focus();
await page.getByTestId('source').dragTo(page.getByTestId('target'));

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('checkbox')).toBeChecked();
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByTestId('card')).toHaveClass(/highlighted/);
await expect(page.getByTestId('card')).toHaveAttribute('data-status', 'active');
await expect(page.getByText('Error')).not.toBeVisible();

// Soft assertions (do not stop the test on failure)
await expect.soft(page.getByText('Title')).toHaveText('Expected');

Page Navigation and Waiting

await page.goto('https://example.com');
await page.goto('/relative-path');       // uses baseURL from config
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

// Mock an API response
await page.route('**/api/users', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, name: 'Alice' }]),
  });
});

// Modify a real response
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 });
});

// Block resources to speed up tests
await page.route('**/*.{png,jpg,jpeg,gif,svg}', route => route.abort());

// Inspect outgoing requests
await page.route('**/api/submit', async route => {
  const postData = route.request().postDataJSON();
  expect(postData.email).toBe('user@example.com');
  await route.continue();
});

// HAR recording and replay
await page.routeFromHAR('tests/data/api.har', { update: true });  // record
await page.routeFromHAR('tests/data/api.har');                     // replay

Authentication

Save login state once, reuse across all tests:

// auth.setup.ts
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 });
});
// In playwright.config.ts projects array
{ 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');

// Handle popup (OAuth window, target="_blank")
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
const popup = await popupPromise;
await popup.waitForLoadState();

// Isolated sessions with separate browser contexts
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

// playwright.config.ts
export default defineConfig({
  fullyParallel: true,   // parallelize tests within a single file
  workers: 4,            // fixed worker count (or '50%' for percentage of CPUs)
});

// Force serial execution for a specific describe block
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                    # see the browser
npx playwright test --debug                     # step through with Inspector
npx playwright codegen https://example.com      # record actions as code
npx playwright show-trace trace.zip             # open trace viewer
npx playwright test --grep "login"              # filter by test name
npx playwright test tests/login.spec.ts         # run specific file

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 });
// ... actions ...
await page.context().tracing.stop({ path: 'trace.zip' });

Page Object Model

// pages/login.page.ts
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) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

// tests/login.spec.ts
test('successful login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await expect(page).toHaveURL('/dashboard');
});

CI/CD Integration

GitHub Actions

# .github/workflows/playwright.yml
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']);  // multiple
await page.getByLabel('Upload').setInputFiles([]);                   // clear

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());      // accept all alerts/confirms
page.on('dialog', dialog => dialog.dismiss());      // dismiss all

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();
});