TL;DR

  • Playwright is Microsoft’s browser automation framework with auto-wait and built-in assertions
  • Supports Chromium, Firefox, and WebKit with a single API
  • TypeScript-first with excellent IDE support and code generation
  • Parallel execution out of the box — runs tests faster than Selenium or Cypress
  • Trace viewer and video recording for debugging failed tests

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 Read time: 15 minutes

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. Auto-wait eliminates timing issues. Parallel execution is free and built-in. The trace viewer shows exactly what happened when a test failed.

What is Playwright?

Playwright is an open-source browser automation framework from Microsoft. It controls Chromium, Firefox, and WebKit through a unified API.

Key features:

  • Auto-wait — waits for elements to be actionable before interacting
  • Web-first assertions — built-in retry logic for assertions
  • Parallel execution — runs tests across multiple workers by default
  • Trace viewer — time-travel debugging for failed tests
  • Codegen — generates tests by recording browser actions

Installation and Setup

Create New Project

# Create new Playwright project
npm init playwright@latest

# Answer prompts:
# - TypeScript or JavaScript? → TypeScript
# - Where to put tests? → tests
# - Add GitHub Actions? → Yes
# - Install browsers? → Yes

Project Structure

my-project/
├── tests/
│   └── example.spec.ts
├── playwright.config.ts
├── package.json
└── .github/
    └── workflows/
        └── playwright.yml

Configuration

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

export default defineConfig({
  testDir: './tests',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,

  reporter: [
    ['html', { open: 'never' }],
    ['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', use: { ...devices['iPhone 14'] } },
  ],
});

Writing Your First Test

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

test('user can login with valid credentials', async ({ page }) => {
  // Navigate
  await page.goto('/login');

  // Fill form
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');

  // Submit
  await page.getByRole('button', { name: 'Sign In' }).click();

  // Assert
  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

# Run all tests
npx playwright test

# Run specific file
npx playwright test tests/login.spec.ts

# Run in headed mode (see browser)
npx playwright test --headed

# Run specific browser
npx playwright test --project=chromium

# Debug mode
npx playwright test --debug

# UI mode (interactive)
npx playwright test --ui

Locators: Finding Elements

Playwright recommends user-facing locators over CSS/XPath selectors.

// By role (accessibility-based)
page.getByRole('button', { name: 'Submit' })
page.getByRole('link', { name: 'Home' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('checkbox', { name: 'Remember me' })

// By label (form inputs)
page.getByLabel('Email')
page.getByLabel('Password')

// By placeholder
page.getByPlaceholder('Search...')

// By text
page.getByText('Welcome')
page.getByText('Welcome', { exact: true })

// By test ID (when other options fail)
page.getByTestId('submit-button')

CSS and XPath (When Needed)

// CSS selector
page.locator('button.primary')
page.locator('[data-testid="submit"]')
page.locator('#login-form input[type="email"]')

// XPath
page.locator('xpath=//button[contains(text(), "Submit")]')

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

Locator Best Practices

PriorityLocatorExample
1RolegetByRole('button', { name: 'Submit' })
2LabelgetByLabel('Email')
3PlaceholdergetByPlaceholder('Search')
4TextgetByText('Welcome')
5Test IDgetByTestId('submit-btn')
6CSSlocator('.btn-primary')

Assertions

Playwright’s assertions auto-retry until timeout.

Common Assertions

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

test('assertions examples', async ({ page }) => {
  await page.goto('/dashboard');

  // 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');

  // Attributes
  await expect(page.getByRole('button')).toBeEnabled();
  await expect(page.getByRole('button')).toBeDisabled();
  await expect(page.getByRole('checkbox')).toBeChecked();

  // URL and title
  await expect(page).toHaveURL('/dashboard');
  await expect(page).toHaveURL(/dashboard/);
  await expect(page).toHaveTitle('My App - Dashboard');

  // Count
  await expect(page.getByRole('listitem')).toHaveCount(5);

  // Value
  await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
});

Soft Assertions

test('soft assertions continue after failure', async ({ page }) => {
  await page.goto('/profile');

  // Soft assertions don't stop the test
  await expect.soft(page.getByText('Name')).toBeVisible();
  await expect.soft(page.getByText('Email')).toBeVisible();
  await expect.soft(page.getByText('Phone')).toBeVisible();

  // Test continues even if some soft assertions fail
  await page.getByRole('button', { name: 'Edit' }).click();
});

Page Object Model

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

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

  constructor(page: Page) {
    this.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');
  });
});

API Testing

Playwright includes built-in API testing capabilities.

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

test.describe('API Tests', () => {
  test('GET request', async ({ request }) => {
    const response = await request.get('/api/users/1');

    expect(response.ok()).toBeTruthy();
    expect(response.status()).toBe(200);

    const user = await response.json();
    expect(user.email).toBe('user@example.com');
  });

  test('POST request', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: {
        name: 'John Doe',
        email: 'john@example.com'
      }
    });

    expect(response.status()).toBe(201);
    const user = await response.json();
    expect(user.id).toBeDefined();
  });

  test('authenticated request', async ({ request }) => {
    const response = await request.get('/api/profile', {
      headers: {
        'Authorization': 'Bearer token123'
      }
    });

    expect(response.ok()).toBeTruthy();
  });
});

Network Interception

test('mock API response', async ({ page }) => {
  // Mock the API
  await page.route('/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Mock User', email: 'mock@example.com' }
      ])
    });
  });

  await page.goto('/users');
  await expect(page.getByText('Mock User')).toBeVisible();
});

test('intercept and modify response', async ({ page }) => {
  await page.route('/api/products', async (route) => {
    const response = await route.fetch();
    const json = await response.json();

    // Modify response
    json.products = json.products.map(p => ({
      ...p,
      price: p.price * 0.9 // 10% discount
    }));

    await route.fulfill({ response, json });
  });

  await page.goto('/products');
});

test('block requests', async ({ page }) => {
  // Block analytics
  await page.route('**/*google-analytics*', route => route.abort());
  await page.route('**/*.{png,jpg,jpeg}', route => route.abort());

  await page.goto('/');
});

Debugging

Trace Viewer

# Enable traces
npx playwright test --trace on

# View traces
npx playwright show-trace trace.zip

Debug Mode

# Step through test
npx playwright test --debug

# Pause at specific point
await page.pause();

Screenshots and Video

// playwright.config.ts
export default defineConfig({
  use: {
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    trace: 'on-first-retry',
  },
});

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: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run tests
        run: npx playwright test

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

AI-Assisted Playwright Development

AI tools integrate well with Playwright’s readable API.

What AI does well:

  • Generating tests from user stories or requirements
  • Converting Selenium/Cypress tests to Playwright
  • Writing Page Object classes from HTML structure
  • Creating assertions for complex data validation
  • Explaining Playwright API methods and patterns

What still needs humans:

  • Test strategy and coverage decisions
  • Debugging visual or timing-related failures
  • Choosing between locator strategies
  • Performance optimization for large test suites

Useful prompt:

I have a checkout flow with these steps:
1. Add item to cart
2. Click checkout
3. Fill shipping address
4. Select payment method
5. Confirm order
6. Verify order confirmation page

Generate Playwright TypeScript tests with:
- Page Object Model
- Proper assertions at each step
- Error case for invalid payment

FAQ

Is Playwright better than Selenium?

Playwright offers several advantages: auto-wait eliminates most timing issues, execution is faster due to browser protocol communication, and the API is more modern. Selenium has broader browser support (including older versions) and a larger community with more learning resources. For new projects, Playwright is usually the better choice. For existing Selenium projects with large test suites, migration cost may not be worth it.

Is Playwright free to use?

Yes, completely. Playwright is open-source under Apache 2.0 license. Unlike Cypress, there are no paid tiers or enterprise features. Parallel execution, trace viewer, video recording — all free. The only cost is your own CI infrastructure.

Can Playwright test mobile apps?

Playwright tests mobile web browsers through device emulation — it simulates iPhone, Android, and tablet viewports. For native mobile apps (iOS/Android apps from app stores), you need Appium or platform-specific tools like XCUITest or Espresso.

What languages does Playwright support?

Playwright officially supports TypeScript, JavaScript, Python, Java, and C#. TypeScript/JavaScript have the most features (component testing, API testing) and best documentation. Python is excellent for teams already using pytest. Java and C# are good for enterprise environments.

When to Choose Playwright

Choose Playwright when:

  • Starting a new test automation project
  • Team uses TypeScript/JavaScript
  • Need fast parallel execution
  • Want modern debugging tools (trace viewer)
  • Testing across Chromium, Firefox, WebKit

Consider alternatives when:

  • Need real Safari on macOS (Selenium + Safari)
  • Large existing Selenium infrastructure
  • Team prefers Python/Java-first tooling
  • Need component testing in React/Vue (Cypress has better DX)

Official Resources

See Also