}

Playwright Python Tutorial 2026: End-to-End Testing and Browser Automation

Playwright Python Tutorial 2026: End-to-End Testing and Browser Automation

Playwright has become the dominant browser automation tool for Python in 2026. Backed by Microsoft and now a CNCF Incubating project, it overtook Selenium in new project adoption in 2024 and has not looked back. Search interest is up roughly 60% year-over-year. The reasons are straightforward: Playwright is faster, more reliable, has better developer ergonomics, and ships with a test runner that works across Chromium, Firefox, and WebKit in a single install.

This tutorial walks you through everything from basic navigation to parallel test execution, visual regression testing, network mocking, and CI integration with GitHub Actions.


TL;DR

  • Install: pip install playwright && playwright install
  • Use the sync API for scripts and quick automation; use the async API for high-concurrency scraping or when integrating with an async web framework
  • Prefer semantic locators (get_by_role, get_by_label, get_by_text) over CSS selectors
  • Use expect(page.get_by_role(...)).to_be_visible() for assertions — Playwright retries automatically
  • pytest-playwright gives you browser fixtures, page objects, and video recording out of the box
  • The Page Object Model keeps test code maintainable as your suite grows
  • Run tests in parallel with pytest-xdist: pytest -n auto
  • Mock network requests with page.route() to isolate front-end tests from back-end flakiness
  • GitHub Actions: use the official microsoft/playwright-python Docker image or the playwright install step

Playwright vs Selenium vs Cypress in 2026: Why Playwright Wins

Three tools still dominate end-to-end testing conversations in 2026. Here is how they compare on the dimensions that matter day-to-day.

FeaturePlaywrightSeleniumCypress
Browser supportChromium, Firefox, WebKitAll browsers via WebDriverChromium-family only
Language supportPython, JS, Java, C#, GoAll major languagesJavaScript / TypeScript only
Auto-waitingBuilt-in, configurableManual waits requiredBuilt-in
Network interceptionFirst-class page.route()Complex, driver-levelcy.intercept()
Parallel executionBuilt-in sharding + pytest-xdistSelenium GridCypress Cloud (paid)
Install size~200 MB (bundled browsers)~50 MB + browser drivers~300 MB
Shadow DOMSupportedManual pierceLimited
iframesframe_locator()Manual switchcy.within()
Mobile emulationDevice descriptors built-inThird-party (Appium)Experimental
LicenseApache 2.0Apache 2.0MIT (runner), Commercial (cloud)

Why Playwright wins in 2026:

Selenium requires you to manage browser drivers (chromedriver, geckodriver) separately, handle explicit waits for nearly every interaction, and live with a WebDriver protocol that was designed in 2012. The gap in developer experience is wide.

Cypress is JavaScript-only. If your team writes Python, Cypress is not an option. Even for JavaScript teams, Cypress's single-tab limitation and inability to test across origins without plugins creates friction on real-world applications.

Playwright ships with bundled browsers, auto-waiting on every action and assertion, a first-class network interception API, built-in screenshot and video recording, and a Python API that feels idiomatic. It supports all three major browser engines, including WebKit (Safari's engine), which matters because Safari bugs are real and Apple's market share is not going away.


Installation

Python 3.8 or later is required. Playwright ships the browser binaries as a separate download step, keeping the PyPI package small.

pip install playwright
playwright install

playwright install downloads Chromium, Firefox, and WebKit into ~/.cache/ms-playwright (Linux/macOS) or %LOCALAPPDATA%\ms-playwright (Windows). To install a specific browser only:

playwright install chromium
playwright install firefox
playwright install webkit

To also install operating-system-level dependencies on a fresh Linux server (common in CI):

playwright install --with-deps chromium

Install pytest-playwright for the test runner integration:

pip install pytest-playwright

Verify the installation:

playwright --version
# Playwright 1.44.0 (or later)

Sync vs Async API: When to Use Each

Playwright for Python ships two APIs that are functionally identical but differ in how they block.

Sync API

The sync API uses Python's threading model under the hood. Each call blocks until Playwright responds. This is the right choice for:

  • Test suites (pytest-playwright uses the sync API exclusively)
  • Simple one-off automation scripts
  • Any context where asyncio is not already in use
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com")
    print(page.title())
    browser.close()

Async API

The async API is built on asyncio. Use it when:

  • You are running many browsers in parallel for scraping or load simulation
  • You are integrating Playwright into a FastAPI or Starlette application
  • You already have an async event loop and want to avoid thread overhead
import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        await page.goto("https://example.com")
        print(await page.title())
        await browser.close()

asyncio.run(main())

For the rest of this tutorial, code examples use the sync API unless noted. In pytest-playwright, everything is sync by default.


Basic Navigation, Clicking, and Filling Forms

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)  # headed for debugging
    page = browser.new_page()

    # Navigation
    page.goto("https://the-internet.herokuapp.com/login")

    # Filling form fields
    page.get_by_label("Username").fill("tomsmith")
    page.get_by_label("Password").fill("SuperSecretPassword!")

    # Clicking a button
    page.get_by_role("button", name="Login").click()

    # Waiting for navigation to complete
    page.wait_for_url("**/secure")

    # Reading text content
    message = page.get_by_css(".flash.success").inner_text()
    print(message)  # "You logged into a secure area!"

    browser.close()

Playwright auto-waits before every action. When you call .click(), Playwright waits for the element to be visible, stable (not animating), and enabled before dispatching the click event. You rarely need explicit time.sleep() or wait_for_selector() calls.

Key navigation methods:

page.goto("https://example.com")           # navigate to URL
page.go_back()                             # browser back
page.go_forward()                          # browser forward
page.reload()                             # reload page
page.wait_for_load_state("networkidle")   # wait until no network activity
page.wait_for_url("**/dashboard")         # wait for URL pattern

Locators: Best Practices

Locators are Playwright's way of finding elements. The cardinal rule: prefer locators that reflect how users find elements, not implementation details like CSS classes or database IDs.

Preferred: Semantic Locators

# By ARIA role and accessible name
page.get_by_role("button", name="Submit")
page.get_by_role("heading", name="Welcome back")
page.get_by_role("link", name="Sign in")
page.get_by_role("textbox", name="Email address")

# By label text (great for form inputs)
page.get_by_label("Email address")
page.get_by_label("Password")

# By visible text
page.get_by_text("Forgot your password?")
page.get_by_text("Forgot your password?", exact=True)

# By placeholder
page.get_by_placeholder("Search...")

# By alt text (images)
page.get_by_alt_text("Company logo")

# By test id (set data-testid in your HTML)
page.get_by_test_id("submit-button")

When CSS Selectors Are Acceptable

Use CSS selectors when the element has no semantic role and adding a data-testid is not an option:

page.locator(".flash-message")
page.locator("#main-content > h1")
page.locator("table tbody tr:nth-child(2) td:nth-child(3)")

Chaining and Filtering Locators

# Find a row in a table that contains "Alice", then get the Edit button in that row
page.get_by_role("row").filter(has_text="Alice").get_by_role("button", name="Edit").click()

# Scope a locator inside a parent element
sidebar = page.locator(".sidebar")
sidebar.get_by_role("link", name="Settings").click()

Why Avoid CSS Selectors as a Default

CSS classes and IDs change during refactoring. A test that relies on .btn-primary or #app > div:nth-child(3) breaks whenever a developer renames a class or restructures the DOM. Semantic locators survive refactoring because they match what users see.


Assertions with expect()

Playwright's expect() function implements web-first assertions: it retries the assertion for up to 5 seconds (by default) before failing. This eliminates the most common source of test flakiness — race conditions between your assertion and the page updating.

from playwright.sync_api import expect

# Visibility
expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()
expect(page.locator(".spinner")).to_be_hidden()

# Text content
expect(page.get_by_role("status")).to_have_text("Saved successfully")
expect(page.get_by_role("status")).to_contain_text("Saved")

# Input values
expect(page.get_by_label("Username")).to_have_value("alice")

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

# Page title
expect(page).to_have_title("Dashboard | MyApp")

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

# Checked state
expect(page.get_by_label("Accept terms")).to_be_checked()

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

# CSS class
expect(page.locator(".alert")).to_have_class(re.compile(r"alert-success"))

# Attribute
expect(page.get_by_role("img", name="Logo")).to_have_attribute("src", "/logo.png")

Configure the timeout globally in pytest.ini or conftest.py:

# conftest.py
from playwright.sync_api import expect

expect.set_options(timeout=10_000)  # 10 seconds

pytest-playwright: Fixtures, Browser Contexts, and Pages

pytest-playwright provides built-in fixtures that handle browser lifecycle, context isolation, and cleanup.

Built-in Fixtures

FixtureScopeDescription
browsersessionA single browser instance shared across all tests
contextfunctionA fresh browser context (isolated cookies, storage) per test
pagefunctionA new page in the function-scoped context
browser_typesessionThe browser type object (chromium/firefox/webkit)
# test_login.py
def test_login_success(page):
    page.goto("https://example.com/login")
    page.get_by_label("Username").fill("alice")
    page.get_by_label("Password").fill("secret123")
    page.get_by_role("button", name="Login").click()
    expect(page).to_have_url("https://example.com/dashboard")

def test_login_wrong_password(page):
    page.goto("https://example.com/login")
    page.get_by_label("Username").fill("alice")
    page.get_by_label("Password").fill("wrong")
    page.get_by_role("button", name="Login").click()
    expect(page.get_by_role("alert")).to_have_text("Invalid credentials")

Configuring pytest-playwright via pytest.ini

# pytest.ini
[pytest]
addopts = --browser chromium --headed --screenshot on

Command-line Options

# Run in a specific browser
pytest --browser firefox
pytest --browser webkit
pytest --browser chromium

# Run in all browsers
pytest --browser chromium --browser firefox --browser webkit

# Headed mode (see the browser)
pytest --headed

# Slow motion (useful for debugging)
pytest --slowmo 500

# Save screenshots on failure
pytest --screenshot on

# Record video for every test
pytest --video on

# Capture full-page screenshots
pytest --screenshot on --full-page-screenshot

Page Object Model Pattern

The Page Object Model (POM) separates the details of how to interact with a page from the logic of what to test. Each page in your application gets a Python class that encapsulates its locators and actions.

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


class LoginPage:
    def __init__(self, page: Page):
        self.page = page
        self.username_input = page.get_by_label("Username")
        self.password_input = page.get_by_label("Password")
        self.submit_button = page.get_by_role("button", name="Login")
        self.error_alert = page.get_by_role("alert")

    def navigate(self):
        self.page.goto("/login")
        return self

    def login(self, username: str, password: str):
        self.username_input.fill(username)
        self.password_input.fill(password)
        self.submit_button.click()
        return self

    def expect_error(self, message: str):
        expect(self.error_alert).to_have_text(message)
        return self
# pages/dashboard_page.py
from playwright.sync_api import Page, expect


class DashboardPage:
    def __init__(self, page: Page):
        self.page = page
        self.welcome_heading = page.get_by_role("heading", name=re.compile(r"Welcome"))
        self.logout_button = page.get_by_role("button", name="Log out")

    def expect_loaded(self):
        expect(self.page).to_have_url(re.compile(r"/dashboard"))
        expect(self.welcome_heading).to_be_visible()
        return self

    def logout(self):
        self.logout_button.click()
        return self
# tests/test_auth.py
import pytest
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage


@pytest.fixture
def login_page(page):
    return LoginPage(page)


@pytest.fixture
def dashboard_page(page):
    return DashboardPage(page)


def test_successful_login(login_page, dashboard_page):
    login_page.navigate().login("alice", "secret123")
    dashboard_page.expect_loaded()


def test_failed_login(login_page):
    login_page.navigate().login("alice", "wrongpassword")
    login_page.expect_error("Invalid credentials")


def test_logout(login_page, dashboard_page):
    login_page.navigate().login("alice", "secret123")
    dashboard_page.expect_loaded().logout()
    expect(login_page.page).to_have_url(re.compile(r"/login"))

The POM pattern pays off when the UI changes. When the "Login" button gets renamed "Sign in", you update one line in LoginPage rather than searching every test file.


Screenshots and Video Recording

Taking Screenshots

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

# Screenshot of a specific element
page.get_by_role("main").screenshot(path="screenshots/main-content.png")

# Screenshot as bytes (useful in CI pipelines)
image_bytes = page.screenshot()

Recording Video

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(
        record_video_dir="videos/",
        record_video_size={"width": 1280, "height": 720},
    )
    page = context.new_page()
    page.goto("https://example.com")
    # ... your test actions
    context.close()  # video is saved on context.close()
    browser.close()

    # Get the path of the recorded video
    video_path = page.video.path()
    print(f"Video saved to: {video_path}")

With pytest-playwright, enable video recording on the command line:

pytest --video on              # record for failed tests only
pytest --video retain-on-failure
pytest --video off             # default

Videos are saved to the test-results/ directory alongside traces and screenshots.

Playwright Traces

Traces are the most powerful debugging tool in Playwright. They capture a full timeline of your test including DOM snapshots, network requests, screenshots, and console logs. View them in the Playwright Trace Viewer.

# Enable tracing in pytest
pytest --tracing on

# View a trace
playwright show-trace test-results/my-test/trace.zip

Visual Regression Testing: Screenshot Comparisons

Visual regression testing catches unintended UI changes by comparing screenshots pixel-by-pixel against a baseline.

pytest-playwright has no built-in snapshot comparison, but playwright-pytest-snapshot and pixelmatch fill that gap. A simpler and increasingly popular choice in 2026 is integrating with pytest-image-diff:

pip install pytest-image-diff

A lightweight manual approach using Pillow:

# tests/test_visual.py
import hashlib
from pathlib import Path
from PIL import Image, ImageChops
import pytest


BASELINE_DIR = Path("tests/visual-baselines")
ACTUAL_DIR = Path("test-results/visual-actuals")
BASELINE_DIR.mkdir(parents=True, exist_ok=True)
ACTUAL_DIR.mkdir(parents=True, exist_ok=True)


def compare_screenshots(page, name: str, threshold: float = 0.01):
    """Take a screenshot and compare it against the stored baseline."""
    actual_path = ACTUAL_DIR / f"{name}.png"
    baseline_path = BASELINE_DIR / f"{name}.png"

    page.screenshot(path=str(actual_path), full_page=True)

    if not baseline_path.exists():
        # First run: create the baseline
        actual_path.replace(baseline_path)
        pytest.skip(f"Baseline created at {baseline_path}. Re-run to compare.")

    actual = Image.open(actual_path).convert("RGB")
    baseline = Image.open(baseline_path).convert("RGB")

    if actual.size != baseline.size:
        pytest.fail(f"Image sizes differ: {actual.size} vs {baseline.size}")

    diff = ImageChops.difference(actual, baseline)
    pixels = list(diff.getdata())
    total = len(pixels)
    different = sum(1 for p in pixels if any(c > 10 for c in p))
    ratio = different / total

    if ratio > threshold:
        pytest.fail(
            f"Visual diff {ratio:.2%} exceeds threshold {threshold:.2%} for '{name}'"
        )


def test_homepage_visual(page):
    page.goto("https://example.com")
    compare_screenshots(page, "homepage")


def test_login_page_visual(page):
    page.goto("https://example.com/login")
    compare_screenshots(page, "login-page")

For production use, consider Percy or Chromatic, which manage baselines, diffs, and review workflows across your team.


Network Interception: Mocking API Responses

page.route() lets you intercept, modify, or abort network requests. This is essential for:

  • Testing error states that are hard to reproduce against a real API
  • Speeding up tests by eliminating real network calls
  • Testing offline scenarios

Mock a GET request

def test_dashboard_shows_user_data(page):
    # Intercept the API call and return mock data
    def handle_users(route):
        route.fulfill(
            status=200,
            content_type="application/json",
            body='[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]',
        )

    page.route("**/api/users", handle_users)
    page.goto("https://app.example.com/dashboard")
    expect(page.get_by_text("Alice")).to_be_visible()
    expect(page.get_by_text("Bob")).to_be_visible()

Mock an error response

def test_dashboard_shows_error_on_api_failure(page):
    page.route("**/api/users", lambda route: route.fulfill(status=500))
    page.goto("https://app.example.com/dashboard")
    expect(page.get_by_role("alert")).to_contain_text("Failed to load users")

Modify a real response

def test_inject_extra_user(page):
    def add_user(route):
        response = route.fetch()
        data = response.json()
        data.append({"id": 99, "name": "Injected User"})
        route.fulfill(response=response, json=data)

    page.route("**/api/users", add_user)
    page.goto("https://app.example.com/dashboard")
    expect(page.get_by_text("Injected User")).to_be_visible()

Abort requests

def test_page_loads_without_analytics(page):
    # Block tracking scripts
    page.route("**/*google-analytics*", lambda route: route.abort())
    page.route("**/*segment.io*", lambda route: route.abort())
    page.goto("https://example.com")
    expect(page).to_have_title("Example Domain")

Parallel Test Execution: pytest-xdist Integration

Playwright tests are naturally slow because they launch real browsers. Running them in parallel cuts total test suite time dramatically.

pip install pytest-xdist
# Run tests across 4 worker processes
pytest -n 4

# Auto-detect CPU count
pytest -n auto

# Combine with browser selection
pytest -n auto --browser chromium

pytest-playwright is designed to work with pytest-xdist. Each worker gets its own browser instance. Browser contexts and pages are created fresh per test, so there is no shared state.

Playwright's Own Sharding (for CI)

For very large test suites, use Playwright's sharding to split tests across multiple CI machines:

# Run shard 1 of 4 (on machine 1)
pytest --shard=1/4

# Run shard 2 of 4 (on machine 2)
pytest --shard=2/4

conftest.py for Parallel-safe Fixtures

# conftest.py
import pytest
from playwright.sync_api import Browser, BrowserContext


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args, tmp_path_factory):
    # Give each worker its own storage state directory
    return {
        **browser_context_args,
        "storage_state": None,
    }


@pytest.fixture
def authenticated_page(page, base_url):
    """Log in once and return the page ready for authenticated actions."""
    page.goto(f"{base_url}/login")
    page.get_by_label("Username").fill("testuser")
    page.get_by_label("Password").fill("testpass")
    page.get_by_role("button", name="Login").click()
    page.wait_for_url("**/dashboard")
    return page

Mobile Emulation and Device Testing

Playwright ships with a registry of device descriptors covering popular phones and tablets. Mobile emulation sets the viewport, device pixel ratio, user agent, and touch support.

from playwright.sync_api import sync_playwright, devices

with sync_playwright() as p:
    iphone = devices["iPhone 14"]
    browser = p.webkit.launch()
    context = browser.new_context(**iphone)
    page = context.new_page()
    page.goto("https://example.com")
    page.screenshot(path="iphone14.png")
    browser.close()

Available device names include: iPhone 14, iPhone 14 Pro, iPad (gen 9), Pixel 7, Galaxy S23, and many more. List all available devices:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    for name in sorted(p.devices.keys()):
        print(name)

Mobile Tests in pytest

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


@pytest.fixture(params=["iPhone 14", "Pixel 7"])
def mobile_page(playwright, request):
    device = devices[request.param]
    browser = playwright.webkit.launch() if "iPhone" in request.param else playwright.chromium.launch()
    context = browser.new_context(**device)
    page = context.new_page()
    yield page
    context.close()
    browser.close()


# tests/test_mobile.py
def test_mobile_navigation(mobile_page):
    mobile_page.goto("https://example.com")
    # On mobile, a hamburger menu replaces the nav bar
    mobile_page.get_by_role("button", name="Menu").click()
    expect(mobile_page.get_by_role("navigation")).to_be_visible()

Geolocation and Permissions

context = browser.new_context(
    geolocation={"longitude": -122.4194, "latitude": 37.7749},
    permissions=["geolocation"],
    locale="en-US",
    timezone_id="America/Los_Angeles",
)

Headless vs Headed Mode, CI Configuration

Headless Mode (Default)

By default, Playwright runs browsers in headless mode — no visible window. This is correct for CI. Headless mode is faster and does not require a display server.

browser = p.chromium.launch(headless=True)  # default

Headed Mode (Local Debugging)

During development, run headed to watch the browser:

browser = p.chromium.launch(headless=False, slow_mo=100)

slow_mo adds a delay (in milliseconds) between each action, making it easier to follow what Playwright is doing.

Headed Mode in pytest

pytest --headed
pytest --headed --slowmo 200

Running in CI Without a Display Server

On Linux CI runners without a display, use xvfb-run for headed mode (though headless is strongly preferred):

xvfb-run pytest --headed

Or use the Docker image which includes all dependencies:

docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/playwright/python:v1.44.0-jammy pytest

GitHub Actions Workflow for Playwright Tests

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

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

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    strategy:
      fail-fast: false
      matrix:
        browser: [chromium, firefox, webkit]
        shard: [1, 2, 3, 4]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install pytest-playwright pytest-xdist

      - name: Install Playwright browsers
        run: playwright install --with-deps ${{ matrix.browser }}

      - name: Run Playwright tests
        run: |
          pytest tests/ \
            --browser ${{ matrix.browser }} \
            --shard=${{ matrix.shard }}/4 \
            --screenshot on \
            --video retain-on-failure \
            --tracing retain-on-failure \
            -v
        env:
          BASE_URL: ${{ secrets.STAGING_URL }}
          TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
          TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-results-${{ matrix.browser }}-shard${{ matrix.shard }}
          path: test-results/
          retention-days: 7

      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.browser }}-shard${{ matrix.shard }}
          path: playwright-report/
          retention-days: 7

Caching Browser Binaries

Browser downloads are slow. Cache them across runs:

      - name: Cache Playwright browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
          restore-keys: |
            playwright-${{ runner.os }}-

      - name: Install Playwright browsers (if cache miss)
        run: playwright install --with-deps chromium

Minimal Single-Browser Workflow

For teams that only test Chromium and want a simpler setup:

name: E2E Tests

on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pytest pytest-playwright
      - run: playwright install --with-deps chromium
      - run: pytest tests/ --browser chromium --screenshot on
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-results
          path: test-results/

FAQ

Q: Do I need a running browser installed on my system? No. playwright install downloads self-contained browser builds that are independent of any system browser. Playwright manages them entirely.

Q: Can Playwright test Chrome extensions? Yes, but only in headed Chromium with a persistent context. Headless mode does not support extensions.

Q: How do I handle authentication without logging in for every test? Use browser.new_context(storage_state="auth.json") to restore a previously saved login state. Save it once in a setup fixture:

# conftest.py
@pytest.fixture(scope="session")
def auth_state(browser, base_url, tmp_path_factory):
    context = browser.new_context()
    page = context.new_page()
    page.goto(f"{base_url}/login")
    page.get_by_label("Username").fill("testuser")
    page.get_by_label("Password").fill("testpass")
    page.get_by_role("button", name="Login").click()
    state_path = str(tmp_path_factory.mktemp("auth") / "state.json")
    context.storage_state(path=state_path)
    context.close()
    return state_path

@pytest.fixture
def authenticated_context(browser, auth_state):
    context = browser.new_context(storage_state=auth_state)
    yield context
    context.close()

Q: How do I test file uploads? Use set_input_files():

page.get_by_label("Upload file").set_input_files("path/to/file.pdf")

For multiple files:

page.get_by_label("Upload files").set_input_files(["file1.pdf", "file2.pdf"])

Q: How do I handle alert(), confirm(), and prompt() dialogs?

page.on("dialog", lambda dialog: dialog.accept())
page.on("dialog", lambda dialog: dialog.dismiss())
page.on("dialog", lambda dialog: dialog.accept("my input text"))

Q: How do I run tests against a local development server? Use pytest-playwright's base_url fixture and --base-url flag:

pytest --base-url http://localhost:3000

Then in tests:

def test_home(page, base_url):
    page.goto(base_url)

Q: Is Playwright suitable for scraping in 2026? Yes, but check the site's robots.txt and terms of service first. For large-scale scraping, use the async API to run multiple browser contexts concurrently. Playwright handles dynamic JavaScript-rendered pages that tools like requests + BeautifulSoup cannot.

Q: How does Playwright compare to Selenium 4? Selenium 4 introduced the W3C WebDriver standard and BiDi (bidirectional) protocol support, which closes some gaps. But Playwright's auto-waiting, network interception, and out-of-the-box multi-browser support remain superior for most use cases. Selenium's main advantage is its larger ecosystem and support for every browser including legacy IE.


Sources

Leonardo Lazzaro

Software engineer and technical writer. 10+ years experience in DevOps, Python, and Linux systems.

More articles by Leonardo Lazzaro