JavaScript Playwright: Browser Automation Testing

Playwright is Microsoft's answer to browser automation testing, and it's rapidly becoming the default choice for teams building modern web applications. Unlike Selenium, which feels like it was...

Key Insights

  • Playwright’s auto-waiting and locator API eliminate the flaky tests that plague Selenium, while its cross-browser support surpasses Cypress’s Chromium-only limitation.
  • Use accessibility-first locators (getByRole, getByLabel) instead of CSS selectors—your tests become more resilient and you catch accessibility issues for free.
  • Storage state reuse cuts authentication overhead dramatically; authenticate once, save the state, and inject it into subsequent tests.

Introduction to Playwright

Playwright is Microsoft’s answer to browser automation testing, and it’s rapidly becoming the default choice for teams building modern web applications. Unlike Selenium, which feels like it was designed in a different era (because it was), Playwright embraces async JavaScript natively and handles the timing nightmares that make browser tests flaky.

The framework supports Chromium, Firefox, and WebKit out of the box. That last one matters—WebKit powers Safari, and if you’ve ever shipped a bug that only appeared on iOS, you understand why testing against it locally is valuable. Cypress, despite its popularity, still only runs on Chromium-based browsers in production environments.

Playwright’s architecture runs tests out-of-process, communicating with browsers via the DevTools Protocol. This gives you capabilities that in-process tools can’t match: multiple browser contexts, true parallelization, and the ability to intercept network requests before they hit the browser.

Setup and Configuration

Getting started takes about thirty seconds:

npm init playwright@latest

This command scaffolds everything you need: the configuration file, example tests, and optionally installs browsers. Say yes to the browsers—Playwright manages its own browser binaries, which eliminates the “works on my machine” problem.

Your project structure will look like this:

├── playwright.config.ts
├── tests/
│   └── example.spec.ts
├── tests-examples/
│   └── demo-todo-app.spec.ts
└── package.json

The configuration file is where you define how tests run. Here’s a practical setup:

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html', { open: 'never' }],
    ['json', { outputFile: 'test-results/results.json' }]
  ],
  
  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'] },
    },
  ],

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

The webServer option is particularly useful—Playwright will start your dev server before tests and shut it down after. No more forgetting to run your app before testing.

Writing Your First Test

Playwright tests follow a straightforward pattern: arrange, act, assert. The framework provides a test function and an expect assertion library that understands async operations.

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

test('user can add item to cart', async ({ page }) => {
  // Navigate to the product page
  await page.goto('/products/mechanical-keyboard');
  
  // Click the add to cart button
  await page.getByRole('button', { name: 'Add to Cart' }).click();
  
  // Verify the cart badge updates
  await expect(page.getByTestId('cart-count')).toHaveText('1');
  
  // Verify success message appears
  await expect(page.getByRole('alert')).toContainText('Added to cart');
});

Notice there’s no await page.waitForSelector() or explicit timeouts. Playwright’s auto-waiting handles this automatically. When you call click(), Playwright waits for the element to be visible, enabled, and stable before clicking. When you assert with expect(), it retries until the condition passes or times out.

This auto-waiting behavior eliminates an entire category of flaky tests. The default timeout is 30 seconds for actions and 5 seconds for assertions, both configurable.

Locators and Selectors

Locators are Playwright’s abstraction for finding elements. Unlike raw selectors, locators are lazy—they don’t query the DOM until you perform an action. This means you can define locators at the top of your test and they’ll always find the current state of the element.

The framework strongly encourages accessibility-first locators:

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

test('locator strategies comparison', async ({ page }) => {
  await page.goto('/signup');
  
  // Best: Role-based (accessible, resilient)
  const submitButton = page.getByRole('button', { name: 'Create Account' });
  
  // Good: Label-based (tests accessibility)
  const emailInput = page.getByLabel('Email address');
  
  // Good: Placeholder for inputs without labels
  const searchInput = page.getByPlaceholder('Search products...');
  
  // Acceptable: Test IDs (explicit contract with developers)
  const promoSection = page.getByTestId('promotional-banner');
  
  // Avoid: CSS selectors (brittle, tied to implementation)
  const fragileButton = page.locator('.btn.btn-primary.submit-form');
  
  // Avoid: XPath (unreadable, extremely brittle)
  const terribleLocator = page.locator('//div[@class="form"]/button[1]');
  
  // Fill the form using good locators
  await emailInput.fill('user@example.com');
  await page.getByLabel('Password').fill('securepassword123');
  await submitButton.click();
});

Role-based locators query the accessibility tree, not the DOM. This means your tests verify that screen readers can navigate your application. You’re getting accessibility testing for free.

When you need to chain locators, use filter() and locator():

// Find the "Delete" button within a specific list item
const todoItem = page.getByRole('listitem').filter({ hasText: 'Buy groceries' });
await todoItem.getByRole('button', { name: 'Delete' }).click();

Handling Common Scenarios

Real applications have authentication, file handling, and API dependencies. Playwright handles all of these.

Authentication with Storage State

Logging in before every test wastes time. Instead, authenticate once and reuse the session:

// 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('test@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  
  // Wait for authentication to complete
  await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
  
  // Save signed-in state
  await page.context().storageState({ path: authFile });
});

Configure this as a setup project in your config:

projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { 
      ...devices['Desktop Chrome'],
      storageState: 'playwright/.auth/user.json',
    },
    dependencies: ['setup'],
  },
],

Mocking API Responses

Network interception lets you test edge cases without backend changes:

test('displays error when API fails', async ({ page }) => {
  // Intercept the API call and return an error
  await page.route('**/api/products', (route) => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal server error' }),
    });
  });
  
  await page.goto('/products');
  
  await expect(page.getByRole('alert')).toContainText(
    'Failed to load products. Please try again.'
  );
});

Debugging and Reporting

When tests fail, you need to understand why. Playwright’s debugging tools are excellent.

The Playwright Inspector lets you step through tests interactively:

npx playwright test --ui

For CI failures, traces are invaluable. They capture screenshots, DOM snapshots, network requests, and console logs at each step:

// In playwright.config.ts
use: {
  trace: 'on-first-retry', // Only capture traces on retry
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
},

You can also capture traces programmatically for specific scenarios:

test('complex checkout flow', async ({ page, context }) => {
  // Start tracing
  await context.tracing.start({ screenshots: true, snapshots: true });
  
  try {
    await page.goto('/checkout');
    // ... test steps
  } finally {
    // Save trace regardless of outcome
    await context.tracing.stop({ path: 'checkout-trace.zip' });
  }
});

View traces with:

npx playwright show-trace checkout-trace.zip

CI/CD Integration

Playwright runs headlessly by default in CI environments. Here’s a production-ready GitHub Actions workflow:

name: Playwright Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps
      
      - name: Run Playwright tests
        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.shardIndex }}
          path: playwright-report/
          retention-days: 7

The sharding configuration splits your test suite across four parallel jobs. For a suite of 100 tests, each job runs approximately 25. This scales linearly—add more shards as your suite grows.

Set fail-fast: false so all shards complete even if one fails. You want the full picture of what’s broken, not just the first failure.

Playwright is the most capable browser automation tool available today. Its auto-waiting eliminates flakiness, its locator API encourages accessible applications, and its debugging tools make failure investigation straightforward. If you’re starting a new project or suffering through Selenium maintenance, make the switch.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.