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.
Reach out to KEYDAL for Playwright E2E suite design and CI integration. Contact us