Toolmingo
Guides10 min read

Playwright Cheat Sheet: E2E Testing for Web Apps

A complete Playwright cheat sheet — selectors, actions, assertions, page objects, API testing, screenshots, and CI setup. Copy-ready patterns for E2E testing.

Playwright is the leading E2E testing framework for web apps. It runs tests in Chromium, Firefox, and WebKit — in parallel, headlessly, and on CI — with built-in auto-waiting, network interception, and powerful tracing. This cheat sheet covers the full Playwright API.

Quick reference

Task Playwright API
Navigate to URL await page.goto('https://example.com')
Click element await page.click('button')
Type text await page.fill('input', 'hello')
Get element page.locator('h1')
Assert text await expect(locator).toHaveText('Title')
Assert visible await expect(locator).toBeVisible()
Wait for URL await page.waitForURL('**/dashboard')
Screenshot await page.screenshot({ path: 'out.png' })
Intercept network await page.route('**/api/**', handler)
New tab/page const [popup] = await Promise.all([page.waitForEvent('popup'), page.click('a')])
Evaluate JS await page.evaluate(() => document.title)
Keyboard press await page.keyboard.press('Enter')
Hover await page.hover('.menu-item')
Select option await page.selectOption('select', 'value')
Upload file await page.setInputFiles('input[type=file]', 'file.pdf')
Check checkbox await page.check('input[type=checkbox]')
Get attribute await page.getAttribute('a', 'href')
Count elements await page.locator('li').count()
Wait for element await page.locator('.spinner').waitFor({ state: 'hidden' })
Mock API response await page.route('/api/users', r => r.fulfill({ json: [] }))
Run API request await request.get('https://api.example.com/users')
Set cookie await context.addCookies([{ name: 'token', value: '123', ... }])
Clear storage await context.clearCookies()

Installation and setup

npm init playwright@latest
# Installs Playwright + browsers + generates playwright.config.ts
# Manual install
npm install -D @playwright/test
npx playwright install          # install browsers
npx playwright install chromium # one browser only

playwright.config.ts

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,           // per-test timeout
  expect: { timeout: 5_000 },// assertion timeout
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',  // record traces on failures
    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',   use: { ...devices['Pixel 5'] } },
  ],

  // Spin up dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Writing tests

import { test, expect } from '@playwright/test';

test('homepage loads', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/My App/);
  await expect(page.locator('h1')).toBeVisible();
});

test.describe('Auth flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('logs in successfully', async ({ page }) => {
    await page.fill('[name=email]', 'user@example.com');
    await page.fill('[name=password]', 'secret');
    await page.click('button[type=submit]');
    await page.waitForURL('**/dashboard');
    await expect(page.locator('h1')).toHaveText('Dashboard');
  });

  test('shows error on bad creds', async ({ page }) => {
    await page.fill('[name=email]', 'bad@example.com');
    await page.fill('[name=password]', 'wrong');
    await page.click('button[type=submit]');
    await expect(page.locator('.error')).toContainText('Invalid credentials');
  });
});

Locators (selectors)

Playwright recommends role-based and user-visible selectors over CSS/XPath.

// Role-based (recommended — resilient)
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { level: 1 })
page.getByRole('link', { name: 'Sign up' })
page.getByRole('checkbox', { name: 'Remember me' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('combobox', { name: 'Country' })
page.getByRole('listitem')

// Text
page.getByText('Welcome back')
page.getByText(/error/i)              // regex

// Label
page.getByLabel('Password')

// Placeholder
page.getByPlaceholder('Search...')

// Test ID (recommended for stable selectors)
page.getByTestId('submit-btn')        // data-testid="submit-btn"

// Alt text (images)
page.getByAltText('Company logo')

// Title attribute
page.getByTitle('Close dialog')

// CSS (fallback)
page.locator('.card > h2')
page.locator('#main-content')
page.locator('input[type=email]')

// XPath (last resort)
page.locator('xpath=//button[@class="primary"]')

// Chaining locators
page.locator('.card').filter({ hasText: 'Premium' }).getByRole('button')
page.locator('ul').locator('li').nth(2)

// Multiple elements
const items = page.locator('li');
const count = await items.count();
const first = items.first();
const last = items.last();
const third = items.nth(2);       // 0-indexed

Assertions (expect)

All Playwright assertions auto-retry until the condition is met or timeout is reached.

// Page assertions
await expect(page).toHaveTitle('My App');
await expect(page).toHaveURL('https://example.com/dashboard');
await expect(page).toHaveURL(/\/dashboard/);

// Locator assertions
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeEmpty();           // input is empty
await expect(locator).toBeFocused();

await expect(locator).toHaveText('Hello');
await expect(locator).toHaveText(/hello/i);
await expect(locator).toContainText('ell');
await expect(locator).toHaveValue('input value');
await expect(locator).toHaveAttribute('href', '/about');
await expect(locator).toHaveClass('active');
await expect(locator).toHaveClass(/btn-/);
await expect(locator).toHaveCount(5);
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');

// Negation
await expect(locator).not.toBeVisible();
await expect(locator).not.toHaveText('Error');

// Custom timeout
await expect(locator).toBeVisible({ timeout: 10_000 });

// API response assertions
await expect(response).toBeOK();            // status 200-299

Actions

// Navigation
await page.goto('https://example.com');
await page.goto('/about');                  // relative to baseURL
await page.goBack();
await page.goForward();
await page.reload();

// Clicking
await page.click('button');
await page.dblclick('.item');
await page.click('text=Submit');
await page.click('button', { button: 'right' });  // right-click
await page.click('.link', { modifiers: ['Shift'] });

// Typing
await page.fill('input[name=email]', 'user@example.com');  // clear + type
await page.type('textarea', 'Hello', { delay: 100 });      // character by character
await page.press('input', 'Enter');
await page.keyboard.type('Hello World');
await page.keyboard.press('Control+A');
await page.keyboard.press('Backspace');

// Form controls
await page.check('input[type=checkbox]');
await page.uncheck('input[type=checkbox]');
await page.selectOption('select', 'value');
await page.selectOption('select', { label: 'Option Label' });
await page.selectOption('select', ['val1', 'val2']);   // multi-select

// File upload
await page.setInputFiles('input[type=file]', 'path/to/file.pdf');
await page.setInputFiles('input[type=file]', ['file1.jpg', 'file2.jpg']);
await page.setInputFiles('input[type=file]', []);      // clear selection

// Hover and focus
await page.hover('.tooltip-trigger');
await page.focus('input[name=email]');

// Drag and drop
await page.dragAndDrop('#source', '#target');

Waiting strategies

Playwright auto-waits for elements to be actionable. Use explicit waits only when needed.

// Wait for navigation
await page.waitForURL('**/dashboard');
await page.waitForURL(/\/profile\/\d+/);

// Wait for element state
await page.locator('.spinner').waitFor({ state: 'hidden' });
await page.locator('.result').waitFor({ state: 'visible' });
await page.locator('#chart').waitFor({ state: 'attached' });

// Wait for network
await page.waitForResponse('**/api/users');
await page.waitForRequest('**/api/**');
await page.waitForLoadState('networkidle');
await page.waitForLoadState('domcontentloaded');

// Wait with expect (preferred)
await expect(page.locator('.toast')).toBeVisible();
await expect(page.locator('.toast')).toBeHidden();

// Wait for function
await page.waitForFunction(() => window.__READY__ === true);

Network interception and mocking

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

// Block requests (ads, analytics)
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
await page.route('**/analytics/**', route => route.abort());

// Modify request
await page.route('**/api/**', async route => {
  const request = route.request();
  await route.continue({
    headers: { ...request.headers(), 'x-custom': 'value' },
  });
});

// Capture response
const responsePromise = page.waitForResponse('**/api/data');
await page.click('#load-btn');
const response = await responsePromise;
const body = await response.json();

// Assert request was made
const requestPromise = page.waitForRequest(req =>
  req.url().includes('/api/login') && req.method() === 'POST'
);
await page.click('button[type=submit]');
await requestPromise;

API testing (without browser)

import { test, expect } from '@playwright/test';

test('GET /users returns list', async ({ request }) => {
  const response = await request.get('/api/users');
  expect(response.status()).toBe(200);
  const body = await response.json();
  expect(body).toHaveLength(3);
  expect(body[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
});

test('POST /users creates user', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { name: 'Alice', email: 'alice@example.com' },
  });
  expect(response.status()).toBe(201);
  const user = await response.json();
  expect(user.id).toBeDefined();
});

test('DELETE /users/:id', async ({ request }) => {
  const response = await request.delete('/api/users/42');
  expect(response.ok()).toBeTruthy();
});

Page Object Model (POM)

// pages/login-page.ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(private page: Page) {
    this.emailInput    = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton  = page.getByRole('button', { name: 'Log in' });
    this.errorMessage  = page.locator('.error-message');
  }

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

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test('login success', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'secret');
  await expect(page).toHaveURL(/\/dashboard/);
});

test('login failure', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('bad@example.com', 'wrong');
  await loginPage.expectError('Invalid credentials');
});

Fixtures (custom test context)

// fixtures.ts
import { test as base, Page } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { DashboardPage } from './pages/dashboard-page';

type Fixtures = {
  loginPage: LoginPage;
  authenticatedPage: Page;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },

  // Fixture that auto-logs in
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.fill('[name=email]', 'user@example.com');
    await page.fill('[name=password]', 'secret');
    await page.click('button[type=submit]');
    await page.waitForURL('**/dashboard');
    await use(page);
  },
});

// Usage in test
import { test } from './fixtures';

test('dashboard shows user name', async ({ authenticatedPage: page }) => {
  await expect(page.locator('.user-name')).toHaveText('Alice');
});

Screenshots and visual testing

// Capture screenshots
await page.screenshot({ path: 'screenshot.png' });
await page.screenshot({ path: 'fullpage.png', fullPage: true });
await page.locator('.card').screenshot({ path: 'card.png' });

// Visual comparison (snapshot testing)
await expect(page).toHaveScreenshot('homepage.png');
await expect(page.locator('.chart')).toHaveScreenshot('chart.png');

// Update snapshots
// npx playwright test --update-snapshots

// Configure threshold
await expect(page).toHaveScreenshot('page.png', {
  maxDiffPixels: 100,
  threshold: 0.2,       // 0-1, percentage of pixels that can differ
});

Multiple tabs, popups, and iframes

// Handle popup window
const [popup] = await Promise.all([
  page.waitForEvent('popup'),
  page.click('a[target=_blank]'),
]);
await popup.waitForLoadState();
await expect(popup).toHaveTitle('New Window');

// New tab via context
const newPage = await context.newPage();
await newPage.goto('https://other.com');

// Iframe
const frame = page.frameLocator('iframe[title="Payment"]');
await frame.locator('input[name=card]').fill('4242 4242 4242 4242');
await frame.getByRole('button', { name: 'Pay' }).click();

// All frames
for (const frame of page.frames()) {
  console.log(frame.url());
}

Authentication state (reuse login)

// global-setup.ts — log in once and save session
import { chromium, FullConfig } from '@playwright/test';

async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('http://localhost:3000/login');
  await page.fill('[name=email]', 'user@example.com');
  await page.fill('[name=password]', 'secret');
  await page.click('button[type=submit]');
  await page.waitForURL('**/dashboard');
  // Save session (cookies + localStorage)
  await page.context().storageState({ path: 'auth-state.json' });
  await browser.close();
}
export default globalSetup;

// playwright.config.ts
export default defineConfig({
  globalSetup: './global-setup.ts',
  use: {
    storageState: 'auth-state.json',  // reuse session for all tests
  },
  // Override per project:
  projects: [
    {
      name: 'authenticated',
      use: { storageState: 'auth-state.json' },
    },
    {
      name: 'unauthenticated',
      use: { storageState: undefined },
    },
  ],
});

CLI commands

Command Description
npx playwright test Run all tests
npx playwright test login Run tests matching "login"
npx playwright test --project=chromium One browser only
npx playwright test --headed Show browser window
npx playwright test --debug Debug mode (step through)
npx playwright test --ui Open interactive UI mode
npx playwright test --trace on Always record traces
npx playwright show-report Open HTML report
npx playwright show-trace trace.zip Open trace viewer
npx playwright codegen https://example.com Record tests interactively
npx playwright test --update-snapshots Update visual snapshots
npx playwright install Install all browsers
npx playwright install chromium Install one browser

CI/CD setup

# .github/workflows/playwright.yml
name: Playwright Tests
on:
  push: { branches: [main] }
  pull_request: { branches: [main] }

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    # browsers + system deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Common mistakes

Mistake Problem Fix
page.click() on hidden element Fails with "not visible" await locator.waitFor({ state: 'visible' }) or use force: true carefully
await page.locator('li').click() with multiple matches Strict mode error Use .first(), .nth(n), or .filter() to target one element
Using page.waitForTimeout(2000) Flaky, slow tests Use expect(locator).toBeVisible() — auto-waits
Forgetting await on assertions Assert passes immediately (false positive) Always await expect(...)
Hardcoded localhost:3000 URLs Breaks CI config Use baseURL in config and relative paths
page.$() / page.$$() Old Playwright API Use page.locator() — it auto-waits
Not resetting state between tests Test pollution Use beforeEach or test.use({ storageState: undefined })
XPath and CSS selectors Brittle, break on refactor Prefer getByRole, getByLabel, getByTestId

FAQ

Q: Playwright vs Cypress — which should I use?
Playwright supports multiple browsers (Chromium/Firefox/WebKit), runs tests in parallel by default, and supports multiple tabs/iframes natively. Cypress has better DevTools integration but is Chromium-only (Firefox/WebKit are experimental). Playwright is now the recommended choice for new projects.

Q: How do I run tests in parallel?
Tests run in parallel by default when fullyParallel: true is set. Each worker gets its own browser context. Use test.describe.configure({ mode: 'serial' }) to run a suite sequentially.

Q: How do I debug a failing test?
Run npx playwright test --debug to pause on each step, or npx playwright test --ui for the interactive UI. Add await page.pause() anywhere to pause execution. After a CI failure, open the HTML report (npx playwright show-report) and view the trace.

Q: How do I test authenticated pages without logging in every test?
Use globalSetup to log in once and save session with storageState. Apply storageState: 'auth-state.json' in use config to reuse cookies across all tests.

Q: How do I handle flaky tests?
Set retries: 2 in config for CI. Use expect assertions instead of waitForTimeout. Avoid page.$() (no auto-wait). Use { timeout } option to increase timeout for slow operations. Enable trace: 'on-first-retry' to diagnose failures.

Q: Can Playwright test APIs without a browser?
Yes. Use the request fixture in tests — it gives you an APIRequestContext that supports GET/POST/PUT/DELETE. No browser is launched. Useful for testing REST APIs or seeding data before UI tests.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools