TL;DR
- Playwright is Microsoft’s browser automation framework — auto-wait, built-in assertions, 3 browser engines
- Setup in 60 seconds:
npm init playwright@latestcreates project with config, sample test, and CI workflow- TypeScript-first with best-in-class IDE support, code generation, and accessibility-based locators
- Free parallel execution out of the box — 3-5x faster than sequential Selenium or Cypress
- Trace Viewer + UI Mode for debugging — see DOM, network, console at every test step
- Built-in API testing, authentication reuse, and visual regression
Best for: Teams wanting modern tooling, TypeScript support, and fast parallel execution Skip if: Need Safari on real devices or have large existing Selenium infrastructure
Your Selenium tests run for 45 minutes. Your Cypress tests can’t run in parallel without paying for their cloud. Your testers spend hours debugging flaky waits.
Playwright solves these problems. I switched our team’s 200-test Selenium suite to Playwright — execution dropped from 42 minutes to 8 minutes on the same CI runner, and flaky test rate went from 12% to under 2%.
What is Playwright?
Playwright is an open-source browser automation framework from Microsoft. It controls Chromium, Firefox, and WebKit through a unified API using browser-specific protocols (not WebDriver).
Why it’s different from Selenium:
- Auto-wait — waits for elements to be actionable before interacting (no
sleep()or explicit waits) - Web-first assertions —
expect(locator).toBeVisible()retries until timeout - Browser contexts — isolated sessions in ~50ms (vs full browser restart)
- Trace viewer — time-travel debugging with DOM snapshots, network, console
- Codegen — generates tests by recording browser actions
Ecosystem in 2026: 67K+ GitHub stars, 1.5M+ weekly npm downloads, used by Microsoft, Google, Netflix, and Stripe.
Installation and Setup
Create New Project (60 Seconds)
npm init playwright@latest
# Choose: TypeScript, tests folder, GitHub Actions, install browsers
This creates:
my-project/
├── tests/
│ └── example.spec.ts # Sample test
├── playwright.config.ts # Configuration
├── package.json
└── .github/workflows/
└── playwright.yml # CI pipeline (ready to use)
Production Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
...(process.env.CI ? [['github'] as const] : []),
],
use: {
baseURL: process.env.BASE_URL || '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 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Key settings explained:
fullyParallel: true— run all tests in parallel across workerstrace: 'on-first-retry'— capture traces only when retrying failed tests (saves disk)webServer— automatically starts your dev server before tests
Writing Your First Test
import { test, expect } from '@playwright/test';
test('user can login with valid credentials', async ({ page }) => {
await page.goto('/login');
// Use accessible locators — role-based, not CSS
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign In' }).click();
// Web-first assertions — auto-retry until visible or timeout
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpass');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
await expect(page).toHaveURL('/login');
});
Running Tests
npx playwright test # All tests, all browsers
npx playwright test tests/login.spec.ts # Specific file
npx playwright test --project=chromium # Single browser
npx playwright test --headed # See browser window
npx playwright test --debug # Step-by-step debugger
npx playwright test --ui # Interactive UI mode
npx playwright codegen localhost:3000 # Record tests
Locators: Finding Elements the Right Way
Playwright recommends accessibility-based locators. They’re resilient to DOM changes and improve your app’s accessibility.
Locator Priority
| Priority | Locator | Example | Why |
|---|---|---|---|
| 1 | Role | getByRole('button', { name: 'Submit' }) | Accessibility-based, resilient |
| 2 | Label | getByLabel('Email') | Form input standard |
| 3 | Placeholder | getByPlaceholder('Search') | Useful for search inputs |
| 4 | Text | getByText('Welcome') | Visible content |
| 5 | Test ID | getByTestId('submit-btn') | Last resort, explicit |
| 6 | CSS | locator('.btn-primary') | Fragile, avoid if possible |
Advanced Locator Patterns
// Filter by content
page.locator('.card').filter({ hasText: 'Premium Plan' }).getByRole('button');
// Nth element
page.getByRole('listitem').nth(2);
page.getByRole('listitem').first();
page.getByRole('listitem').last();
// Chaining (within a section)
page.getByRole('navigation').getByRole('link', { name: 'Settings' });
// Has nested element
page.locator('article').filter({
has: page.getByRole('heading', { name: 'Pricing' })
});
Assertions
Playwright assertions auto-retry until timeout (default 5 seconds). No await page.waitForSelector() needed.
Common Assertions
// Visibility
await expect(page.getByText('Welcome')).toBeVisible();
await expect(page.getByText('Loading')).toBeHidden();
// Text content
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.getByRole('heading')).toContainText('Dash');
// State
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('checkbox')).toBeChecked();
// URL and title
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle('My App');
// Count and value
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
// Soft assertions (don't stop the test)
await expect.soft(page.getByText('Name')).toBeVisible();
await expect.soft(page.getByText('Email')).toBeVisible();
Page Object Model
// pages/LoginPage.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: 'Sign In' });
this.errorMessage = page.getByRole('alert');
}
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/LoginPage';
test.describe('Login', () => {
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');
});
test('invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('wrong@example.com', 'wrong');
await loginPage.expectError('Invalid credentials');
});
});
Authentication: Reuse Login State
Don’t log in before every test. Playwright can save and reuse authentication state.
Setup Authentication Once
// auth.setup.ts
import { test as setup, expect } 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 expect(page).toHaveURL('/dashboard');
// Save signed-in state
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
Now every test starts already logged in — saving 2-5 seconds per test.
Fixtures: Custom Test Setup
Playwright fixtures are dependency injection for tests. They replace beforeEach/afterEach with composable, type-safe setup.
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
type MyFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
},
});
export { expect } from '@playwright/test';
// tests/dashboard.spec.ts
import { test, expect } from '../fixtures';
test('dashboard shows user stats', async ({ dashboardPage }) => {
// dashboardPage is already initialized via fixture
await expect(dashboardPage.statsPanel).toBeVisible();
});
API Testing
Playwright includes built-in API testing — no need for Supertest or Axios.
test.describe('API Tests', () => {
test('CRUD user flow', async ({ request }) => {
// CREATE
const createResponse = await request.post('/api/users', {
data: { name: 'John', email: 'john@example.com' }
});
expect(createResponse.status()).toBe(201);
const user = await createResponse.json();
expect(user.id).toBeDefined();
// READ
const getResponse = await request.get(`/api/users/${user.id}`);
expect(getResponse.ok()).toBeTruthy();
const fetched = await getResponse.json();
expect(fetched.email).toBe('john@example.com');
// DELETE
const deleteResponse = await request.delete(`/api/users/${user.id}`);
expect(deleteResponse.status()).toBe(204);
});
});
Network Interception
// Mock API response
test('display mocked users', async ({ page }) => {
await page.route('/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mock User' }])
});
});
await page.goto('/users');
await expect(page.getByText('Mock User')).toBeVisible();
});
// Modify real response
test('apply discount to prices', async ({ page }) => {
await page.route('/api/products', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.products = json.products.map(p => ({ ...p, price: p.price * 0.9 }));
await route.fulfill({ response, json });
});
await page.goto('/products');
});
// Block third-party requests (speed up tests)
test('fast page load', async ({ page }) => {
await page.route('**/*google-analytics*', route => route.abort());
await page.route('**/*.{png,jpg}', route => route.abort());
await page.goto('/');
});
Visual Regression Testing
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/');
// Full page screenshot comparison
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixelRatio: 0.01, // Allow 1% pixel difference
});
});
test('checkout form layout', async ({ page }) => {
await page.goto('/checkout');
// Component-level screenshot
const form = page.getByTestId('checkout-form');
await expect(form).toHaveScreenshot('checkout-form.png');
});
First run creates baseline screenshots. Subsequent runs compare against baselines. Update baselines with npx playwright test --update-snapshots.
Debugging
UI Mode (Best for Development)
npx playwright test --ui
Interactive test runner with:
- Watch mode — re-runs on file changes
- Time-travel — click any step to see DOM state
- Pick locator — hover over elements to generate selectors
- Network inspector and console logs
Trace Viewer (Best for CI Failures)
npx playwright test --trace on
npx playwright show-trace test-results/trace.zip
Shows timeline of every action with DOM snapshots, network requests, and console output. Essential for debugging tests that only fail in CI.
VS Code Integration
Install the Playwright VS Code Extension:
- Run/debug tests from editor
- Pick locators by clicking elements
- Live test results inline
- Record new tests
CI/CD Integration
# .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: 22
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
env:
BASE_URL: http://localhost:3000
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14
Sharding for Large Suites
# Split tests across 4 parallel machines
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}
AI-Assisted Playwright Development
AI tools integrate naturally with Playwright’s readable, accessibility-based API.
What AI does well:
- Generating tests from user stories — “user adds item to cart and checks out”
- Converting Selenium/Cypress tests to Playwright syntax
- Writing Page Object classes from a URL or HTML structure
- Creating network mocking configurations from API specs
- Explaining why a specific locator or assertion fails
What still needs humans:
- Test strategy and coverage decisions
- Debugging visual or timing-related flaky tests
- Choosing optimal locator strategy for your app’s DOM
- Performance tuning (worker count, sharding, trace settings)
Useful prompt:
Generate Playwright TypeScript tests for a checkout flow: add to cart, fill shipping, select payment, confirm order, verify confirmation. Use Page Object Model, getByRole locators, and include error cases for invalid payment.
FAQ
Is Playwright better than Selenium?
Playwright offers auto-wait (eliminates timing issues), faster execution via browser protocols (not WebDriver), and modern TypeScript API. Selenium has broader legacy browser support and a larger community. For new projects in 2026, Playwright is the better choice. For existing Selenium suites with 1000+ tests, migration cost may outweigh benefits.
Is Playwright free to use?
Yes, completely. Playwright is open-source under Apache 2.0 license. Unlike Cypress, there are no paid tiers. Parallel execution, trace viewer, video recording, visual regression — all free. The only cost is your CI infrastructure.
Can Playwright test mobile apps?
Playwright tests mobile web through device emulation — it simulates iPhone, Android, and tablet viewports with touch events. For native apps from app stores, use Appium or platform-specific tools like XCUITest/Espresso.
What languages does Playwright support?
TypeScript, JavaScript, Python, Java, and C#. TypeScript/JavaScript have the most features (component testing, API testing fixtures) and best documentation. Python is excellent for pytest teams. Java and C# work well in enterprise environments.
How long does it take to learn Playwright?
A developer familiar with web testing can write first tests in 1-2 hours using codegen. Becoming proficient with Page Objects, fixtures, authentication reuse, and CI integration takes about 1-2 weeks. The learning curve is gentler than Selenium thanks to auto-wait and better error messages.
Can Playwright do visual regression testing?
Yes, built-in. await expect(page).toHaveScreenshot() captures and compares screenshots automatically. First run creates baselines, subsequent runs detect pixel differences. Configure sensitivity with maxDiffPixelRatio. Update baselines with --update-snapshots.
Official Resources
See Also
- Playwright Comprehensive Guide - Advanced patterns, fixtures, and best practices
- Playwright vs Cypress - Detailed comparison with benchmarks
- Selenium vs Playwright - Migration guide from Selenium
- Puppeteer vs Playwright - Google vs Microsoft tools
- Cypress Tutorial - Alternative E2E framework
- Selenium Tutorial - Legacy framework basics
- Cross-Browser Test Matrix - Browser coverage strategy
- Visual AI Testing - Visual regression approaches
- Flaky Test Management - Handling unreliable tests
- Test Automation Tutorial - Automation fundamentals
