Back to All Articles
Automation

Playwright End-to-End Testing — Complete Guide

Honnesh Muppala May 5, 2026 16 min read

What is Playwright?

Playwright is an open-source end-to-end testing framework developed and maintained by Microsoft. Released in 2020, it rapidly became one of the most popular browser automation tools due to its multi-browser support, built-in auto-wait, and rich language bindings. Playwright supports Chromium, Firefox, and WebKit (Safari's engine) — giving you genuine cross-browser coverage including Safari without needing a macOS machine.

Unlike Selenium, which relies on browser-specific driver executables (chromedriver, geckodriver), Playwright ships its own browser builds and communicates via the Chrome DevTools Protocol (CDP) for Chromium and a custom protocol for Firefox and WebKit. This tight integration enables features like network interception, geolocation emulation, and browser context isolation that are difficult or impossible to achieve cleanly through the WebDriver protocol.

Language support

Architecture Diagram

Playwright communicates with each browser via browser-specific protocols — no external WebDriver executable required:

Test Code
Python / TypeScript
Playwright Library
Auto-wait · Locators · Expect
CDP / WebKit Protocol
Low-level browser protocol
Browser
Chromium / Firefox / Safari
Playwright ships its own browser builds — no separate chromedriver/geckodriver required.

Installation

Python installation

# Install the Playwright package
pip install playwright

# Install browser binaries (Chromium, Firefox, WebKit)
playwright install

# Install pytest plugin for Playwright
pip install pytest-playwright

TypeScript / Node.js installation

# Create a new project with Playwright initialized
npm init playwright@latest

# Playwright installs browsers automatically
# Project structure created:
# playwright.config.ts
# tests/example.spec.ts
# tests-examples/demo-todo-app.spec.ts

First Test in Python

Python tests use the synchronous sync_playwright API or the async API with asyncio. The pytest plugin handles browser lifecycle automatically via fixtures:

# tests/test_login.py
from playwright.sync_api import Page, expect

def test_valid_login(page: Page):
    page.goto("https://example.com/login")

    page.get_by_label("Email").fill("user@example.com")
    page.get_by_label("Password").fill("SecurePass123")
    page.get_by_role("button", name="Log in").click()

    # Assert dashboard loaded
    expect(page).to_have_url("https://example.com/dashboard")
    expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()

def test_invalid_password(page: Page):
    page.goto("https://example.com/login")

    page.get_by_label("Email").fill("user@example.com")
    page.get_by_label("Password").fill("WrongPassword")
    page.get_by_role("button", name="Log in").click()

    error = page.get_by_role("alert")
    expect(error).to_be_visible()
    expect(error).to_contain_text("Invalid email or password")

Run with: pytest tests/ -v

Locators

Playwright's locator API is designed to reflect how users and accessibility tools perceive the page — rather than exposing raw DOM queries. This makes tests more resilient to markup changes.

# Role-based locators (recommended — mirrors accessibility tree)
page.get_by_role("button", name="Submit")
page.get_by_role("link", name="Sign up")
page.get_by_role("textbox", name="Search")

# Label-based (best for form inputs)
page.get_by_label("Email address")
page.get_by_label("Password")

# Placeholder text
page.get_by_placeholder("Enter your email")

# Visible text
page.get_by_text("Welcome back")

# Test ID (data-testid attribute — Playwright's equivalent of data-cy)
page.get_by_test_id("submit-button")

# Alt text for images
page.get_by_alt_text("Company logo")

# CSS selector (fallback)
page.locator(".submit-btn")
page.locator("#login-form input[type='email']")

# Chaining locators
page.locator(".user-card").get_by_role("button", name="Edit")

# nth element
page.get_by_role("listitem").nth(2)

Auto-Wait Explained

Every Playwright action waits for the element to satisfy a set of actionability conditions before proceeding. The specific checks depend on the action type:

Action Actionability Checks
click() Visible, stable (not animating), enabled, receives events
fill() Visible, enabled, editable
check() Visible, enabled, not already checked
select_option() Visible, enabled
expect(locator).to_be_visible() Retries until visible or timeout
expect(locator).to_have_text() Retries until text matches or timeout

This means you almost never need to write time.sleep() or explicit wait loops. Playwright's internal polling handles the timing for you, up to the configured timeout (default: 30 seconds for assertions, 30 seconds for actions).

Assertions

Playwright's expect() API provides web-specific assertions with automatic retry:

from playwright.sync_api import expect

# Visibility
expect(page.get_by_role("dialog")).to_be_visible()
expect(page.locator(".spinner")).to_be_hidden()

# Text
expect(page.get_by_role("heading")).to_have_text("Welcome, Alice")
expect(page.get_by_role("status")).to_contain_text("Success")

# Value (form fields)
expect(page.get_by_label("Email")).to_have_value("user@example.com")

# URL
expect(page).to_have_url("https://example.com/dashboard")
expect(page).to_have_url(re.compile(r"/dashboard"))

# Title
expect(page).to_have_title("Dashboard — MyApp")

# Count
expect(page.get_by_role("listitem")).to_have_count(5)

# Enabled state
expect(page.get_by_role("button", name="Submit")).to_be_enabled()
expect(page.get_by_role("button", name="Submit")).to_be_disabled()

# Checked state
expect(page.get_by_role("checkbox")).to_be_checked()

# Attribute
expect(page.get_by_role("link")).to_have_attribute("href", "/pricing")

Page Object Model in Python

The Page Object Model keeps test logic clean and locators centralised. Here is a complete LoginPage implementation with Playwright:

# pages/login_page.py
from playwright.sync_api import Page, expect

class LoginPage:
    URL = "/login"

    def __init__(self, page: Page):
        self.page = page
        self.email_input = page.get_by_label("Email")
        self.password_input = page.get_by_label("Password")
        self.login_button = page.get_by_role("button", name="Log in")
        self.error_alert = page.get_by_role("alert")

    def navigate(self):
        self.page.goto(self.URL)

    def login(self, email: str, password: str):
        self.email_input.fill(email)
        self.password_input.fill(password)
        self.login_button.click()

    def expect_error(self, message: str):
        expect(self.error_alert).to_be_visible()
        expect(self.error_alert).to_contain_text(message)

    def expect_dashboard(self):
        expect(self.page).to_have_url("/dashboard")
# tests/test_login_pom.py
from pages.login_page import LoginPage

def test_successful_login(page):
    login = LoginPage(page)
    login.navigate()
    login.login("user@example.com", "SecurePass123")
    login.expect_dashboard()

def test_invalid_credentials(page):
    login = LoginPage(page)
    login.navigate()
    login.login("user@example.com", "BadPassword")
    login.expect_error("Invalid email or password")

Pytest Fixtures

The pytest-playwright plugin provides playwright, browser, and page fixtures out of the box. You can build your own fixtures on top of them:

# conftest.py
import pytest
from playwright.sync_api import Page

@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    """Override browser context settings for all tests."""
    return {
        **browser_context_args,
        "viewport": {"width": 1280, "height": 720},
        "base_url": "https://staging.example.com",
        "ignore_https_errors": True,
    }

@pytest.fixture
def logged_in_page(page: Page):
    """Provide a pre-authenticated page to tests that need it."""
    # Log in via API to skip the UI
    page.request.post("/api/auth/login", data={
        "email": "testuser@example.com",
        "password": "SecurePass123"
    })
    page.goto("/dashboard")
    yield page

# Usage in a test:
def test_dashboard_content(logged_in_page: Page):
    expect(logged_in_page.get_by_role("heading", name="Dashboard")).to_be_visible()
From Experience at Viasat: When I introduced Playwright to our web testing workflow, the logged_in_page fixture pattern was the first performance win we achieved. Our login flow involved a SAML redirect sequence that took 4–6 seconds per test in Selenium. By authenticating via the API and injecting session cookies, we cut test setup time by over 80%. Across a suite of 150 tests, this saved more than 12 minutes per CI run.

API Testing with Playwright

Playwright's APIRequestContext lets you make HTTP requests directly within your test suite — useful for seeding data, verifying API responses, or running hybrid UI + API test flows:

from playwright.sync_api import Page, APIRequestContext, expect

def test_api_creates_user(page: Page, playwright):
    api = playwright.request.new_context(base_url="https://api.example.com")

    # POST to create a user
    response = api.post("/users", data={
        "name": "Alice Test",
        "email": "alice@example.com",
        "role": "viewer"
    })
    assert response.ok
    body = response.json()
    assert body["email"] == "alice@example.com"

    # GET the created user
    get_response = api.get(f"/users/{body['id']}")
    assert get_response.ok
    assert get_response.json()["name"] == "Alice Test"

    api.dispose()

def test_ui_reflects_api_data(page: Page, playwright):
    """Create data via API, then verify it appears in the UI."""
    api = playwright.request.new_context(base_url="https://api.example.com")
    resp = api.post("/products", data={"name": "Widget Pro", "price": 49.99})
    product_id = resp.json()["id"]

    page.goto(f"/products/{product_id}")
    expect(page.get_by_role("heading")).to_have_text("Widget Pro")
    api.dispose()

Screenshots and Video

Playwright has first-class support for capturing screenshots and recording test video — invaluable for debugging CI failures:

# Manual screenshot in a test
page.screenshot(path="screenshots/login-error.png")

# Full-page screenshot
page.screenshot(path="screenshots/full-page.png", full_page=True)

# Screenshot of a specific element
page.locator(".error-banner").screenshot(path="screenshots/error.png")

Configure automatic screenshots and video in pytest.ini or via CLI:

# pytest.ini
[pytest]
addopts = --screenshot=only-on-failure --video=retain-on-failure --output=test-results

# Or via CLI
pytest tests/ --screenshot=on --video=on

Multi-Browser Testing

Run the same tests across Chromium, Firefox, and WebKit using the --browser flag or by parametrizing in conftest.py:

# Run on all three browsers from CLI
pytest tests/ --browser chromium --browser firefox --browser webkit

# Or run a specific browser
pytest tests/ --browser webkit    # Tests against Safari engine
# conftest.py — parametrize by browser
import pytest

@pytest.fixture(params=["chromium", "firefox", "webkit"])
def browser_type_launch_args(request, browser_type_launch_args):
    return {**browser_type_launch_args, "browser_name": request.param}

GitHub Actions CI

# .github/workflows/playwright.yml
name: Playwright Tests

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

jobs:
  playwright:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install pytest pytest-playwright
          playwright install --with-deps chromium firefox webkit

      - name: Run Playwright tests
        run: pytest tests/ --screenshot=only-on-failure --video=retain-on-failure

      - name: Upload test artifacts
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-results
          path: test-results/

Tool Comparison

Feature Playwright Cypress Selenium
Language support JS, TS, Python, Java, .NET JavaScript / TypeScript only Java, Python, JS, C#, Ruby
Browser coverage Chromium, Firefox, WebKit (Safari) Chrome, Firefox, Edge, Electron All major browsers
Auto-wait Yes — actionability checks Yes — command retry No — explicit waits required
Speed Very fast Very fast Moderate
API testing Built-in (APIRequestContext) cy.request() (basic) Requires separate library
Mobile emulation Yes (viewport + device profiles) Viewport only Via Appium
Parallel execution Yes (built-in, browser contexts) Yes (dashboard, parallelisation) Yes (Selenium Grid)
Network interception Built-in (route / fulfill) Built-in (cy.intercept) Requires BrowserMob Proxy
Trace / debug Trace Viewer (excellent) Time-travel debugger Screenshot on failure only

Best Practices

1. Prefer role-based locators

Use get_by_role(), get_by_label(), and get_by_placeholder() over CSS selectors. Role-based locators match how assistive technologies see the page and are far more resilient to UI refactoring.

2. Use fixtures for shared setup

Create a logged_in_page fixture that authenticates via API. Never repeat login logic across test files. Scope to session where possible to share browser state across tests that don't modify it.

3. Avoid hardcoded waits

Never use page.wait_for_timeout(2000). Playwright's auto-wait handles the majority of timing issues. For cases where you genuinely need to wait for an event, use page.wait_for_response(), page.wait_for_url(), or page.wait_for_selector() with a specific condition.

4. Run full cross-browser locally before release

Run Chromium only on PR builds for speed. Add Firefox and WebKit to the nightly run or to the release candidate pipeline. Cross-browser issues most commonly surface in Safari (WebKit) — don't skip it before a major release.

From Experience at Amazon: At Amazon, we used Playwright's network interception to build a deterministic test harness for catalog search — a notoriously hard-to-test feature due to live search index dependencies. By intercepting the search API calls and returning fixture responses, we achieved 100% stable, reproducible tests that ran in the same time regardless of the state of the search index in staging. This pattern — isolate external dependencies, own your test data — is the single biggest factor in building a reliable automation suite.

Playwright vs Selenium — When to Choose Which

One of the most common questions teams ask when evaluating Playwright is whether it replaces Selenium. The short answer is: not always. Playwright is a better fit for many modern web projects, but Selenium retains real advantages in specific contexts. Understanding those differences helps you make an informed choice rather than following hype.

Where Playwright wins

Where Selenium still wins

Decision guide

Choose Playwright when: you are starting a greenfield web project with no existing test infrastructure; the application is a modern React, Angular, or Vue SPA; your team writes Python or TypeScript; or you need API testing and UI testing in a single, unified suite without pulling in additional libraries.

Choose Selenium when: you are extending an existing Java or TestNG codebase and a rewrite is not on the table; your enterprise environment relies on Selenium Grid for cross-machine parallel execution; your team's expertise is strongly Java-based and the productivity cost of switching languages outweighs Playwright's feature advantages; or you need to test legacy browsers that Playwright does not support.

Common Playwright pain points

Quick comparison table

Criterion Playwright Selenium
Language support Python, TypeScript, Java, .NET (Python/TS best-supported) Java, Python, C#, Ruby, JavaScript (Java strongest)
Auto-wait Built-in actionability checks on every action Manual — requires explicit WebDriverWait calls
API testing Built-in via APIRequestContext / request fixture Requires separate library (requests, RestAssured)
Mobile testing Emulation only; real devices need external farm Real devices via Appium (same WebDriver protocol)
Community size Large and fast-growing (since 2020) Very large — 20+ years of resources and answers
CI setup complexity Low — one install step, browsers bundled Medium — requires managing driver versions separately
Learning curve Moderate — fixture model and async patterns to learn Low initial, high advanced (Grid, wait strategies)
Job market demand Rising — increasingly listed in modern QA roles High — still dominant in enterprise job postings

Back to Blog
From Experience — Virtusa: Leading a team of 270 testers at Virtusa, we standardised on Appium for real Android device testing and Selenium WebDriver for web regression. The biggest challenge wasn't the tooling — it was consistency across a team that size. We enforced a strict Page Object Model convention and a pre-merge locator review checklist. Within two sprints, flaky test rates dropped significantly and the team achieved a 20% efficiency gain across regression cycles. At that scale, test architecture decisions matter far more than individual test quality.