Playwright is the browser automation framework Microsoft open-sourced in 2020, and today it's the real standard for E2E testing. It drives Chromium, Firefox and WebKit through a single API and overcomes Cypress's long-standing limitations (iframes, multi-tab, file upload).

Installation

npm init playwright@latest
# Prompts: TS or JS, tests folder, GitHub Actions workflow, install browsers

# Run tests
npx playwright test
npx playwright test --headed         # see the browser
npx playwright test --ui             # interactive UI mode
npx playwright show-report           # HTML report

Your First Test

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

test('user can log in', async ({ page }) => {
    await page.goto('https://example.com/login');

    await page.getByLabel('Email').fill('test@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByText('Welcome, Test')).toBeVisible();
});

Selector Strategies

Playwright's preferred order of user-facing selectors:

// 1) Role (accessibility-first — most robust)
page.getByRole('button', { name: 'Save' });
page.getByRole('heading', { level: 1, name: 'Title' });

// 2) Label — for form inputs
page.getByLabel('Email');

// 3) Text — visible text
page.getByText('Welcome');

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

// 5) Test ID (controlled by developers)
page.getByTestId('submit-btn');
// <button data-testid="submit-btn">Save</button>

// LAST RESORT — CSS/XPath
page.locator('.btn-primary');  // brittle, avoid

Auto-wait and Assertions

In Playwright, wait_for is no longer needed — every action automatically waits for the element to be ready. Assertions also retry.

// Auto-wait — element enters DOM, becomes visible, becomes stable
await page.getByRole('button', { name: 'Upload' }).click();

// Retryable assertions (web-first)
await expect(page.getByText('Success')).toBeVisible();  // 5s retry
await expect(page.getByRole('list')).toHaveCount(10);
await expect(page).toHaveTitle('Dashboard');
await expect(page).toHaveURL('/dashboard');

Fixtures and Auth

// playwright.config.ts — persist the login state once
import { defineConfig } from '@playwright/test';

export default defineConfig({
    globalSetup: './global-setup.ts',
    use: {
        storageState: 'storage-state.json',
        baseURL: 'http://localhost:3000',
        trace: 'on-first-retry'
    }
});

// global-setup.ts
import { chromium } from '@playwright/test';

export default async () => {
    const browser = await chromium.launch();
    const page = await browser.newPage();
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@test.com');
    await page.getByLabel('Password').fill('admin');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
    await page.context().storageState({ path: 'storage-state.json' });
    await browser.close();
};

API Mocking

test('user list fetch fails gracefully', async ({ page }) => {
    // Intercept the network
    await page.route('/api/users', route => {
        route.fulfill({ status: 500, body: 'Server error' });
    });

    await page.goto('/users');
    await expect(page.getByText('Something went wrong')).toBeVisible();
});

Mobile and Multi-Browser

// playwright.config.ts
export default defineConfig({
    projects: [
        { name: 'chromium',  use: { ...devices['Desktop Chrome'] } },
        { name: 'firefox',   use: { ...devices['Desktop Firefox'] } },
        { name: 'webkit',    use: { ...devices['Desktop Safari'] } },
        { name: 'mobile',    use: { ...devices['iPhone 14'] } },
        { name: 'tablet',    use: { ...devices['iPad Pro'] } }
    ]
});

// Run all projects
npx playwright test
// Just chromium
npx playwright test --project=chromium

Trace Viewer

To understand when a flaky test fails, the trace is worth its weight in gold. DOM snapshots for every action, network logs and console output.

# Enable in the config: trace: 'on-first-retry'
npx playwright test
npx playwright show-trace trace.zip
# A timeline opens in the browser; click any step to see DOM + screenshot

Parallel Testing

Playwright runs in parallel by default — each .spec.ts in its own worker. Tests within one file run sequentially, but you can parallelize them too with test.describe.parallel.

CI (GitHub Actions)

# .github/workflows/e2e.yml
name: E2E
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 chromium
      - run: npm run build
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Playwright vs Cypress

Conclusion

In 2026, if you're starting a new E2E project, Playwright should be the default. It's fast, reliable, multi-browser and even has Python/Java bindings. Protect 20-30 critical user flows at the top of your test pyramid with Playwright — unit + integration + E2E together make a solid quality net.

E2E testing infrastructure

Reach out to KEYDAL for Playwright E2E suite design and CI integration. Contact us

WhatsApp