jsguides

End-to-end testing with Playwright in JavaScript

What you will learn

End-to-end testing simulates real user interactions with your application, catching bugs that unit and integration tests miss. Playwright, developed by Microsoft, has become the go-to choice for modern end-to-end testing thanks to its speed, reliability, and cross-browser support across Chromium, Firefox, and WebKit.

You will set up Playwright, write your first test, and build a comprehensive test suite for a real web application.

Why choose Playwright?

Playwright stands out among E2E testing tools for several compelling reasons:

  • Cross-browser support: Test on Chromium, Firefox, and WebKit with a single API
  • Auto-wait: Playwright automatically waits for elements to be actionable before interacting
  • Fast execution: Runs tests in parallel across multiple browser contexts
  • Powerful debugging: Built-in trace viewer, VS Code extension, and browser devtools integration
  • Modern features: Supports modern web features like service workers, geolocation, and permissions

Setting up Playwright

First, create a new Node.js project and install Playwright:

mkdir playwright-demo && cd playwright-demo
npm init -y
npm install -D @playwright/test
npx playwright install --with-deps chromium

The install command downloads the browser binaries that Playwright needs to run tests. Chromium is the fastest for local development, but you can add Firefox and WebKit with additional install flags when cross-browser coverage matters. With the tools in place, create a playwright.config.js file to configure your test environment:

import { defineConfig } 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',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { browserName: 'chromium' },
    },
  ],
});

This config sets up parallel execution, retries in CI, and HTML reporting. The baseURL field means every page.goto('/') call resolves relative to your dev server, so you do not need to repeat the full URL in every test. The trace option captures a complete recording on the first retry, which speeds up debugging when a test flakes.

Writing your first test

Create a test file at tests/example.spec.js. We will test a simple task application:

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

test('can add and complete a task', async ({ page }) => {
  await page.goto('/');

  // Add a new task
  await page.fill('input[placeholder="What needs to be done?"]', 'Learn Playwright');
  await page.press('input[placeholder="What needs to be done?"]', 'Enter');

  // Verify the task was added
  await expect(page.locator('.task-list')).toContainText('Learn Playwright');

  // Mark it as complete
  await page.click('.task-list input[type="checkbox"]');

  // Verify it's marked complete
  await expect(page.locator('.task-list li')).toHaveClass(/completed/);
});

This test covers the core flow: navigate to the app, add an item, verify it appeared, mark it complete, and check the visual state updated. Each await ensures the action finished before the assertion runs. Playwright’s built-in auto-wait means you do not need explicit wait calls, fill, press, and click all retry until the element is ready.

Run the tests with:

npx playwright test

Understanding playwright’s locators

Playwright provides multiple ways to locate elements. Choose the right locator for maximum reliability:

Text locators

// Click a button by its text
await page.click('text=Submit');

// Find an element containing specific text
await expect(page.locator('text=Welcome')).toBeVisible();

CSS and xpath

Text locators target the visible content of an element. They are readable and survive minor UI copy changes, but they fall short when the same text appears in multiple places or when the element’s text is generated dynamically. CSS selectors give you finer control by matching on id, class, or attribute values, while XPath handles complex tree-based queries that CSS cannot express.

// CSS selector
await page.click('#submit-button');
await page.click('.btn.primary');

// XPath for more complex queries
await page.click('//button[contains(@class, "submit")]');

CSS selectors break when a class name changes or the DOM is restructured during a refactor. Role-based locators are more resilient because they target elements by their accessible role and name, the same way screen readers and real users identify controls. This makes them the recommended approach for most test scenarios.

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

// Locate by accessible name (recommended)
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
await page.getByRole('link', { name: 'Learn More' }).click();

// Locate by placeholder
await page.getByPlaceholder('Enter your email').fill('test@example.com');

// Locate by label
await page.getByLabel('Remember me').check();

Handling asynchronous operations

Modern web apps rely heavily on asynchronous behavior. Playwright’s auto-wait feature handles most cases, but you’ll occasionally need more control:

Waiting for network requests

// Wait for a specific API call to complete
await page.waitForResponse(response =>
  response.url().includes('/api/todos') && response.status() === 200
);

// Wait for navigation after form submission
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');

Waiting for elements

Network waits couple the test to internal API paths that may change without visible UI impact. Element-based waits are usually the safer choice because they verify what the user actually sees on screen. Only reach for waitForResponse when the DOM does not visibly reflect the network change. For most cases, toBeHidden and toHaveText give you reliable signals that the UI reached the state you expect.

// Wait for an element to appear
await expect(page.locator('.loading-spinner')).toBeHidden();

// Wait for an element to contain specific text
await expect(page.locator('.status')).toHaveText('Ready', { timeout: 10000 });

Element-based waits are the safer default. They verify what the user actually sees: a spinner disappeared, a status message updated, or new content rendered. Use toBeHidden and toHaveText for these cases, and set a generous timeout for operations that involve network round trips or animations.

Assertions deep dive

Playwright assertions retry automatically until the condition passes or the timeout expires. This means toHaveText waits for a network response to update the DOM, and toBeVisible waits for CSS transitions to finish. The retry behaviour eliminates most flaky-test causes without adding explicit sleep calls. The assertion library extends Jest-like expect with powerful matchers:

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

// Basic assertions
await expect(page.locator('h1')).toHaveText('Dashboard');
await expect(page.locator('.count')).toHaveCount(5);

// Value assertions
await expect(page.locator('#username')).toHaveValue('johndoe');

// Attribute assertions
await expect(page.locator('button')).toBeDisabled();
await expect(page.locator('input[type="checkbox"]')).toBeChecked();

// Visibility and state
await expect(page.locator('.modal')).toBeVisible();
await expect(page.locator('.hidden')).toBeHidden();

// Custom assertions with regex
await expect(page.locator('.timestamp')).toMatch(/\d{2}:\d{2}:\d{2}/);

Testing forms

Forms are critical to test thoroughly. Here’s a comprehensive example:

test('registration form with validation', async ({ page }) => {
  await page.goto('/register');

  // Submit empty form
  await page.click('button[type="submit"]');

  // Assert validation errors appear
  await expect(page.locator('#email-error')).toHaveText('Email is required');
  await expect(page.locator('#password-error')).toHaveText('Password is required');

  // Fill with invalid email
  await page.fill('#email', 'notanemail');
  await page.click('button[type="submit"]');
  await expect(page.locator('#email-error')).toHaveText('Invalid email format');

  // Fill with valid data
  await page.fill('#email', 'test@example.com');
  await page.fill('#password', 'SecureP@ss123');
  await page.fill('#confirm-password', 'SecureP@ss123');
  await page.click('button[type="submit"]');

  // Verify successful registration
  await expect(page).toHaveURL('/dashboard');
  await expect(page.locator('.welcome')).toContainText('Welcome, test@example.com');
});

This form test validates the full registration flow: empty submission shows errors, an invalid email triggers a format warning, and valid data leads to the dashboard. Each assertion targets a specific error message element, so the test catches both missing validation and incorrect error copy. Running the assertions in sequence mirrors the real user progression through the form.

Page object model

For larger test suites, use the Page Object Model (POM) to reduce duplication:

// pages/TodoPage.js
export class TodoPage {
  constructor(page) {
    this.page = page;
    this.input = page.locator('input[placeholder*="task"]');
    this.todoList = page.locator('.task-list');
  }

  async addTodo(text) {
    await this.input.fill(text);
    await this.input.press('Enter');
  }

  async completeTodo(text) {
    const task = this.todoList.locator('text=' + text);
    await task.locator('input[type="checkbox"]').click();
  }

  async isCompleted(text) {
    const task = this.todoList.locator('text=' + text);
    return await task.evaluate(el => el.classList.contains('completed'));
  }
}

The Page Object Model wraps selectors and common actions into a class so tests call todoPage.addTodo('Write tests') instead of repeating page.fill('input[...]', ...) across every test. When the selector changes, for example, a placeholder attribute gets updated, you fix it in one place rather than across every test file.

// tests/task.spec.js
import { test } from '@playwright/test';
import { TodoPage } from '../pages/TodoPage';

test('task app with POM', async ({ page }) => {
  const todoPage = new TodoPage(page);

  await page.goto('/');
  await todoPage.addTodo('Write tests');
  await todoPage.addTodo('Run tests');

  await todoPage.completeTodo('Write tests');

  expect(await todoPage.isCompleted('Write tests')).toBe(true);
});

POM tests read like plain English descriptions of user behaviour. The constructor receives the page object and stores locators as instance properties. Action methods like addTodo and completeTodo encapsulate the interaction detail, while query methods like isCompleted let assertions stay in the test body where they belong.

Debugging Playwright tests

When tests fail, Playwright provides powerful debugging tools:

Trace viewer

The trace viewer captures every action, network request, and screenshot:

// In playwright.config.js
use: {
  trace: 'on-first-retry', // Records trace on first retry
}

Run with trace viewer:

npx playwright show-trace test-results/trace.zip

VS code extension

The trace viewer is the most powerful debugging tool in Playwright’s arsenal. It captures a full recording of every action, network request, DOM snapshot, and console message. For flaky tests, stepping through the timeline often reveals the root cause, a late API response, a race condition, or an element that rendered slower than expected, without needing to reproduce the failure locally.

The VS Code extension complements the trace viewer by bringing test execution directly into the editor. Install the Playwright VS Code extension for:

  • Run tests from inline decorators
  • Pick selectors visually
  • Debug with breakpoints
  • Live reload tests

Browser debugging

The VS Code extension adds inline run buttons above each test block, breakpoint support, and a selector picker that lets you click any element in the browser to copy its locator. These features turn the editor into an interactive test development environment instead of a terminal-driven workflow.

test('debug this test', async ({ page }) => {
  await page.goto('/');

  // Pause execution and open devtools
  await page.pause();

  // Continue from here...
});

The page.pause() call opens the Playwright Inspector and freezes the browser at that exact moment. From there you can step through each action one at a time, inspect the DOM, and run locators in the console to verify they resolve correctly. It is the fastest way to understand why a specific interaction is not behaving as expected.

Running tests in ci

Configure your CI pipeline to run Playwright tests:

# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
  test:
    timeout-minutes: 60
    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 Playwright tests
        run: npx playwright test
      - upload-artifact:
          if: always()
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Best practices

Follow these tips for maintainable, reliable test suites:

  1. Use role-based locators: They’re more resilient to UI changes than CSS selectors
  2. Keep tests independent: Each test should work in isolation
  3. Avoid sleeps: Use explicit waits instead
  4. Clean up test data: Delete or reset data after each test
  5. Use test fixtures: Share setup code across tests
  6. Run tests in parallel: Configure fullyParallel: true for speed
  7. Tag tests: Use @tagname annotations to run specific subsets

Conclusion

Playwright provides a reliable, developer-friendly framework for end-to-end testing. Its auto-wait behavior, powerful locators, and excellent debugging tools make it an excellent choice for testing modern web applications.

You have seen how to set up Playwright, write tests using various locator strategies, handle async operations, use assertions effectively, implement the Page Object Model, and debug test failures.

As you build out your test suite, remember that E2E tests are slow by nature, so focus on testing critical user paths rather than every possible interaction. Combine E2E tests with fast unit and integration tests for comprehensive coverage.

Frequently asked questions

How playwright compares to cypress

Playwright supports multiple browsers (Chromium, Firefox, WebKit) while Cypress only supports Chromium-based browsers. Playwright’s auto-wait feature reduces flaky tests, and it handles modern features like iframes and multiple tabs more reliably.

Can i use Playwright with other testing frameworks?

Yes! While Playwright has its own test runner, you can use Playwright with Jest, Mocha, or Vitest. However, the built-in @playwright/test runner is recommended for the best experience.

How do i test mobile websites with Playwright?

Use the deviceScaleFactor and viewport settings to simulate mobile devices:

use: {
  deviceScaleFactor: 2,
  isMobile: true,
  hasTouch: true,
  viewport: { width: 375, height: 812 },
}

Building a stable E2E habit

The best end-to-end suites are small, dependable, and pointed at the paths users care about most. You do not need to test every button on every page through the browser to get value. A handful of tests that cover sign in, purchase, search, or settings can catch a large share of real regressions. Keeping the suite focused makes it easier to run often, which is what gives it real value.

Stable locators matter just as much as the browser itself. If a test relies on brittle selectors, it can fail for reasons that have nothing to do with user experience. Prefer roles, labels, and accessible names where possible because they match how a real user interacts with the page. That choice also gives you better signals when the UI changes, since the test is tied to meaning rather than to presentation details.

Test data should be predictable and disposable. If a suite depends on a shared account, a long-lived fixture, or a manually prepared database row, it becomes harder to trust. A clean setup step that creates what the test needs and tears it down afterward keeps each run independent. That independence is what makes parallel test execution and repeated local runs feel safe.

When a test fails, debugging should be quick. Traces, screenshots, and clear step names are worth their weight because they shorten the path from failure to fix. A good E2E test tells a story about the user journey and makes the broken step easy to find. If the failure takes a long time to understand, the test itself may need to be simplified.

The most useful mindset is to treat E2E tests as a final check, not as the only check. They work best when unit and integration tests have already covered the smaller pieces. In that setup, the browser tests can focus on the complete path and give you confidence that the pieces still fit together when the app is running for real.

Keep one clear assertion per flow

A good browser test usually answers one simple question. Can the user finish the task? Did the form submit? Did the page change? When a test tries to answer too many questions at once, the failure becomes harder to read and the setup becomes more fragile. A single clear assertion keeps the flow easy to scan and makes the purpose of the test obvious.

That does not mean the test has to be tiny. It can still cover a realistic sequence of steps, but each step should move the story forward rather than branch into extra checks. The browser is the right place to prove the full flow works, yet the test still benefits from the same discipline you would use in unit tests: keep the purpose narrow enough that the result is easy to trust.

Next steps

End-to-end tests catch regressions that unit and integration tests miss because they exercise the full stack, browser, network, and server, exactly as a user experiences it. Once your Playwright suite covers the critical user paths, combine it with fast Vitest unit tests for a testing pyramid that gives quick feedback during development and thorough validation before deploy.

See Also