🎭 Python·Mar 2026·7 sections · 30 min read

playwright-python

A production-grade guide to browser automation and end-to-end testing with playwright-python — Microsoft's modern browser testing library. Covers the async and sync API, smart selectors and auto-waiting, page interactions, network interception and mocking, tracing and debugging, pytest fixture patterns, the Page Object Model, parallel execution, and full CI/CD integration with GitHub Actions.

async_playwrightPageLocatorexpectRouteBrowserContextTracingpytest-playwrightPageObjectModelnetwork_mockcodegen--headedwebkitchromiumfirefoxallure

Why Playwright — and What This Skill Covers

Playwright is Microsoft's open-source browser automation framework that supports Chromium, Firefox, and WebKit from a single Python API. Unlike Selenium, it ships with its own browser binaries, has a built-in auto-wait mechanism that eliminates flaky sleeps, and provides first-class async support, network interception, and rich tracing out of the box.

3
Browser engines
supported
2
API modes
async · sync
0
Manual sleeps
needed

The biggest source of flaky tests is timing — waiting for elements to be ready before interacting with them. Playwright's Locator API and expect() assertions are auto-retrying: they poll until the condition is met or a configurable timeout expires, making tests significantly more reliable than Selenium-based suites.

1 — Installation & Project Setup

Playwright requires Python 3.8+ and downloads browser binaries on first install. Use a virtual environment — the binaries are large (~300 MB) and should not be installed globally.

bashinstallation
# Create and activate a virtualenv
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate

# Install the library and pytest plugin
pip install playwright pytest-playwright

# Download browser binaries (chromium + firefox + webkit)
playwright install

# Install only the browsers you need (faster CI)
playwright install chromium
playwright install --with-deps chromium   # also installs OS-level deps (Linux CI)
textrecommended project layout
my-project/
├── tests/
│   ├── conftest.py          ← pytest fixtures (browser, context, page)
│   ├── pages/               ← Page Object Model classes
│   │   ├── __init__.py
│   │   ├── login_page.py
│   │   └── dashboard_page.py
│   ├── test_auth.py
│   └── test_checkout.py
├── playwright.config.py     ← base URL, viewport, timeout defaults
├── pyproject.toml
└── .github/workflows/e2e.yml
bashcode generation — record interactions
# Opens a browser and records your interactions as Python code
playwright codegen https://example.com

# Save to a file
playwright codegen https://example.com --output tests/test_recorded.py

# Record with a specific browser
playwright codegen --browser=firefox https://example.com

# Record with viewport set
playwright codegen --viewport-size="1280,720" https://example.com

2 — Async API vs Sync API

Playwright for Python offers two identical APIs: async (for use with asyncio) and sync (for scripts and pytest without an event loop). The sync API is a thin wrapper that runs the async API in a background thread. For production test suites, prefer the sync API via pytest-playwright — it avoids asyncio boilerplate while keeping parallelism at the worker level.

API ModeImportBest For
Syncfrom playwright.sync_api import sync_playwrightScripts, pytest, most test suites
Asyncfrom playwright.async_api import async_playwrightFastAPI, aiohttp apps, async scraping
pythonsync API — standalone script
from playwright.sync_api import sync_playwright

def run() -> None:
    with sync_playwright() as pw:
        browser = pw.chromium.launch(headless=True)
        context = browser.new_context(
            viewport={"width": 1280, "height": 720},
            locale="en-US",
        )
        page = context.new_page()
        page.goto("https://example.com")
        print(page.title())
        page.screenshot(path="screenshot.png")
        browser.close()

if __name__ == "__main__":
    run()
pythonasync API — for asyncio applications
import asyncio
from playwright.async_api import async_playwright

async def run() -> None:
    async with async_playwright() as pw:
        browser = await pw.chromium.launch(headless=True)
        context = await browser.new_context()
        page = await context.new_page()
        await page.goto("https://example.com")
        print(await page.title())

        # Parallel page operations
        async with asyncio.TaskGroup() as tg:
            tg.create_task(page.wait_for_selector("h1"))
            tg.create_task(page.wait_for_load_state("networkidle"))

        await browser.close()

asyncio.run(run())

3 — Selectors & the Locator API

Playwright's Locator is the recommended way to reference elements. Unlike page.query_selector() (which resolves immediately), page.locator() is lazy — it retries on every action until the element is ready, handling dynamic content without manual waits.

Prefer Role & Text Selectors Over CSS/XPath

Role and text-based selectors test what the user actually sees — they are resilient to markup changes and align with accessibility best practices. Use CSS selectors only for structural fallbacks.

pythonlocator selector reference
# ── Role-based (preferred) ─────────────────────────────────────
page.get_by_role("button", name="Submit")
page.get_by_role("link", name="Sign In")
page.get_by_role("textbox", name="Email")
page.get_by_role("checkbox", name="Remember me")
page.get_by_role("heading", name="Welcome", level=1)

# ── Text / label ────────────────────────────────────────────────
page.get_by_text("Forgot password?")
page.get_by_label("Password")
page.get_by_placeholder("Enter your email")
page.get_by_alt_text("Company logo")
page.get_by_title("Close dialog")

# ── Test IDs (set data-testid on elements) ──────────────────────
page.get_by_test_id("nav-menu")        # matches data-testid="nav-menu"

# ── CSS / XPath (fallback) ───────────────────────────────────────
page.locator("button.btn-primary")
page.locator("//table/tbody/tr[1]/td[2]")

# ── Chaining ─────────────────────────────────────────────────────
page.locator(".product-card").filter(has_text="In Stock").first
page.locator("form").get_by_role("button", name="Submit")
page.locator("ul.nav-items").locator("li").nth(2)

Locator Methods — Actions & Assertions

pythoncommon locator actions
btn = page.get_by_role("button", name="Submit")

# Actions (all auto-wait for actionability)
btn.click()
btn.click(button="right")          # right-click
btn.dblclick()
btn.hover()

input_ = page.get_by_label("Search")
input_.fill("playwright")          # clears first, then types
input_.type("playwright")          # simulates key-by-key typing (slow)
input_.press("Enter")
input_.press("Control+A")
input_.clear()

# Select dropdown
page.get_by_label("Country").select_option("US")
page.get_by_label("Country").select_option(label="United States")

# Checkbox
page.get_by_label("Terms").check()
page.get_by_label("Terms").uncheck()
page.get_by_label("Terms").set_checked(True)

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

# Get values
text   = btn.text_content()
value  = input_.input_value()
count  = page.locator(".item").count()
is_vis = btn.is_visible()
is_ena = btn.is_enabled()

4 — Auto-Wait, Assertions & Timeouts

Never Use time.sleep() in Playwright Tests

Every Playwright action auto-waits for the element to be visible, stable, enabled, and not obscured before interacting. expect() assertions retry until the condition is true or the timeout fires. Hard sleeps make tests slow and still flaky.

pythonexpect assertions — auto-retrying
from playwright.sync_api import expect

# Element state
expect(page.get_by_role("button", name="Submit")).to_be_visible()
expect(page.get_by_role("button", name="Submit")).to_be_enabled()
expect(page.get_by_role("button", name="Submit")).to_be_disabled()
expect(page.get_by_role("button", name="Submit")).to_be_hidden()
expect(page.locator(".spinner")).not_to_be_visible()

# Text content
expect(page.locator("h1")).to_have_text("Welcome, Alice")
expect(page.locator("h1")).to_contain_text("Welcome")
expect(page.locator(".error")).to_have_text(/invalid.*password/i)   # regex

# Input value
expect(page.get_by_label("Username")).to_have_value("alice@example.com")

# Attribute
expect(page.locator("img.logo")).to_have_attribute("alt", "Company Logo")
expect(page.get_by_role("checkbox")).to_be_checked()

# URL
expect(page).to_have_url("https://app.example.com/dashboard")
expect(page).to_have_url(//dashboard$/)
expect(page).to_have_title("Dashboard · My App")

# Count
expect(page.locator(".product-card")).to_have_count(12)

# Custom timeout (default: 5 000 ms)
expect(page.locator(".spinner")).not_to_be_visible(timeout=15_000)

Explicit Wait Patterns

pythonwaiting for events and conditions
# Wait for navigation to complete
page.goto("https://example.com")
page.wait_for_load_state("networkidle")   # no network activity for 500 ms
page.wait_for_load_state("domcontentloaded")

# Wait for a specific URL
page.wait_for_url("**/dashboard")
page.wait_for_url(lambda url: "/dashboard" in url)

# Wait for a response before clicking
with page.expect_response("**/api/login") as resp_info:
    page.get_by_role("button", name="Sign In").click()
response = resp_info.value
assert response.status == 200

# Wait for a request
with page.expect_request("**/api/data") as req_info:
    page.get_by_role("button", name="Load").click()
request = req_info.value
print(request.post_data)

# Wait for popup window
with page.expect_popup() as popup_info:
    page.get_by_role("link", name="Open in new tab").click()
popup = popup_info.value
popup.wait_for_load_state()
print(popup.url)

# Wait for download
with page.expect_download() as dl_info:
    page.get_by_role("button", name="Export CSV").click()
download = dl_info.value
download.save_as("output.csv")

Timeout Configuration

pythontimeout hierarchy
# Global defaults — apply to all tests via conftest.py
from playwright.sync_api import Browser, BrowserContext

def browser_context_args(browser_context_args):
    return {**browser_context_args, "default_timeout": 10_000}

# Per-page override
page.set_default_timeout(15_000)            # all actions + assertions
page.set_default_navigation_timeout(30_000) # goto(), wait_for_url()

# Per-action override (highest priority)
page.locator(".slow-element").click(timeout=20_000)
expect(page.locator(".result")).to_be_visible(timeout=30_000)

# Zero timeout = no wait (immediate check — use sparingly)
page.locator(".banner").click(timeout=0)

5 — Network Interception & Mocking

Playwright can intercept, modify, or block any HTTP/HTTPS request at the browser level. This enables mocking API responses for deterministic tests without a real backend, testing error states, and blocking analytics or ads to speed up tests.

pythonpage.route() — full interception API
import json

# ── Mock a REST API endpoint ─────────────────────────────────────
def handle_users(route):
    route.fulfill(
        status=200,
        content_type="application/json",
        body=json.dumps([
            {"id": 1, "name": "Alice", "role": "admin"},
            {"id": 2, "name": "Bob",   "role": "user"},
        ]),
    )

page.route("**/api/users", handle_users)
page.goto("https://app.example.com/admin/users")

# ── Mock error states ─────────────────────────────────────────────
page.route("**/api/submit", lambda r: r.fulfill(status=500, body="Server Error"))

# ── Block analytics / ads (speed up tests) ────────────────────────
page.route("**/*.{png,jpg,jpeg,gif,webp}", lambda r: r.abort())  # block images
page.route("**/analytics.js", lambda r: r.abort())

# ── Modify request headers ────────────────────────────────────────
def add_auth_header(route, request):
    headers = {**request.headers, "Authorization": "Bearer test-token-123"}
    route.continue_(headers=headers)

page.route("**/api/**", add_auth_header)

# ── Intercept and modify response ────────────────────────────────
def patch_response(route):
    response = route.fetch()              # get real response
    data     = response.json()
    data["feature_flag"] = True           # inject a flag
    route.fulfill(response=response, body=json.dumps(data))

page.route("**/api/config", patch_response)

# ── Remove a route ───────────────────────────────────────────────
page.unroute("**/api/users")

HAR Recording & Replay

pythonrecord network as HAR, replay in tests
# RECORD: Capture all traffic to a HAR file
context = browser.new_context(record_har_path="api.har")
page    = context.new_page()
page.goto("https://app.example.com")
# ... perform user actions ...
context.close()    # flushes HAR file

# REPLAY: Route all requests from the HAR
context = browser.new_context()
context.route_from_har("api.har", not_found="fallback")  # or "abort"
page = context.new_page()
page.goto("https://app.example.com")
# All API calls are served from the HAR — no real network needed

6 — Tracing, Screenshots & Debugging

Playwright's trace viewer is one of its killer features: a complete recording of the test including DOM snapshots, network requests, console logs, and screenshots at every step — all viewable in a browser-based timeline UI.

pythontracing — recording and viewing
# Start a trace before the test
context.tracing.start(
    screenshots=True,   # screenshots at each step
    snapshots=True,     # DOM snapshots for hover inspection
    sources=True,       # include test source code
)

# ... run your test ...
page.goto("https://example.com")
page.get_by_role("button", name="Login").click()

# Stop and save the trace
context.tracing.stop(path="trace.zip")

# Open the trace viewer (Playwright UI)
# playwright show-trace trace.zip

# Group related actions with chunks for named steps
context.tracing.start_chunk(title="Login flow")
page.goto("/login")
page.get_by_label("Email").fill("user@test.com")
context.tracing.stop_chunk(path="login-trace.zip")
pythonscreenshots and video recording
# Full-page screenshot
page.screenshot(path="full.png", full_page=True)

# Element screenshot
page.locator(".chart-widget").screenshot(path="chart.png")

# Screenshot on failure (in conftest.py)
import pytest

@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    rep = outcome.get_result()
    if rep.when == "call" and rep.failed:
        page = item.funcargs.get("page")
        if page:
            page.screenshot(path=f"failures/{item.name}.png", full_page=True)

# Video recording — set on context creation
context = browser.new_context(
    record_video_dir="videos/",
    record_video_size={"width": 1280, "height": 720},
)
# Videos are saved when context.close() is called

Debugging — Inspector & Headed Mode

bashdebug commands
# Run tests in headed mode (visible browser)
pytest tests/ --headed

# Slow down actions by 500 ms (useful to watch interactions)
pytest tests/ --headed --slowmo=500

# Open Playwright Inspector on the current test
PWDEBUG=1 pytest tests/test_auth.py::test_login

# Run only in Chromium (skip firefox and webkit)
pytest tests/ --browser=chromium

# Pause execution inside test (opens inspector)
page.pause()

7 — pytest-playwright Fixtures & Page Object Model

pytest-playwright provides built-in fixtures for playwright, browser, context, and page. Combine these with the Page Object Model pattern to build a maintainable, DRY test suite where every UI interaction lives in one place.

pythonconftest.py — fixtures and configuration
import pytest
from playwright.sync_api import Browser, BrowserContext, Page

# ── Base URL (set once, used everywhere) ─────────────────────────
@pytest.fixture(scope="session")
def base_url() -> str:
    return "https://app.example.com"   # or read from env var

# ── Shared context with auth state ───────────────────────────────
@pytest.fixture(scope="session")
def authenticated_context(browser: Browser, base_url: str) -> BrowserContext:
    context = browser.new_context(base_url=base_url)
    page    = context.new_page()
    page.goto("/login")
    page.get_by_label("Email").fill("admin@example.com")
    page.get_by_label("Password").fill("secret")
    page.get_by_role("button", name="Sign In").click()
    page.wait_for_url("**/dashboard")
    # Persist auth cookies / local storage for all tests in session
    context.storage_state(path=".auth/state.json")
    page.close()
    return context

@pytest.fixture
def auth_page(authenticated_context: BrowserContext) -> Page:
    page = authenticated_context.new_page()
    yield page
    page.close()

# ── Context from saved storage state (even faster) ───────────────
@pytest.fixture
def logged_in_page(browser: Browser, base_url: str) -> Page:
    context = browser.new_context(
        base_url=base_url,
        storage_state=".auth/state.json",    # reuse session without re-login
    )
    page = context.new_page()
    yield page
    context.close()

# ── Global context settings ───────────────────────────────────────
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {
        **browser_context_args,
        "viewport":        {"width": 1280, "height": 720},
        "locale":          "en-US",
        "timezone_id":     "America/New_York",
        "default_timeout": 10_000,
    }

Page Object Model (POM)

Each Page Object wraps one URL/screen, exposing high-level methods instead of raw selectors. Tests become readable prose; selector changes happen in one place.

pythonpages/login_page.py
from __future__ import annotations
from playwright.sync_api import Page, expect


class LoginPage:
    URL = "/login"

    def __init__(self, page: Page) -> None:
        self._page = page
        # Define locators once — reuse in all methods
        self._email    = page.get_by_label("Email")
        self._password = page.get_by_label("Password")
        self._submit   = page.get_by_role("button", name="Sign In")
        self._error    = page.locator(".error-message")

    def navigate(self) -> LoginPage:
        self._page.goto(self.URL)
        return self

    def login(self, email: str, password: str) -> None:
        self._email.fill(email)
        self._password.fill(password)
        self._submit.click()

    def expect_error(self, message: str) -> None:
        expect(self._error).to_be_visible()
        expect(self._error).to_contain_text(message)

    def expect_redirect_to_dashboard(self) -> None:
        expect(self._page).to_have_url("**/dashboard")
pythontests/test_auth.py — clean, readable tests
import pytest
from playwright.sync_api import Page
from tests.pages.login_page import LoginPage


def test_successful_login(page: Page) -> None:
    login = LoginPage(page).navigate()
    login.login("user@example.com", "correct-password")
    login.expect_redirect_to_dashboard()


def test_invalid_credentials(page: Page) -> None:
    login = LoginPage(page).navigate()
    login.login("user@example.com", "wrong-password")
    login.expect_error("Invalid email or password")


def test_empty_email(page: Page) -> None:
    login = LoginPage(page).navigate()
    login.login("", "password")
    login.expect_error("Email is required")


@pytest.mark.parametrize("email,password,error", [
    ("",                "pass",  "Email is required"),
    ("bad",             "pass",  "Invalid email format"),
    ("user@test.com",   "",      "Password is required"),
])
def test_validation_messages(page: Page, email: str, password: str, error: str) -> None:
    LoginPage(page).navigate().login(email, password)
    LoginPage(page).expect_error(error)

Parallel Test Execution

bashparallel execution with pytest-xdist
pip install pytest-xdist

# Run with 4 parallel workers
pytest tests/ -n 4

# Auto-detect CPU count
pytest tests/ -n auto

# Run across all 3 browsers in parallel
pytest tests/ --browser chromium --browser firefox --browser webkit -n auto
inipytest.ini — default configuration
[pytest]
# Default browser (override with --browser on CLI)
addopts =
    --browser=chromium
    --headed=false
    -v
    --tb=short

# Slow tests get extra time
timeout = 60

# Test discovery
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

8 — CI/CD Integration

Playwright tests run headlessly in CI with no extra configuration. The --with-deps flag on playwright install handles OS-level dependencies automatically on Linux runners.

yaml.github/workflows/e2e.yml
name: E2E Tests

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

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        browser: [chromium, firefox, webkit]
      fail-fast: false        # run all browsers even if one fails

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

      - name: Install dependencies
        run: |
          pip install pytest pytest-playwright pytest-xdist
          playwright install --with-deps ${{ matrix.browser }}

      - name: Run E2E tests
        run: |
          pytest tests/ \
            --browser=${{ matrix.browser }} \
            -n auto \
            --tracing=retain-on-failure \
            --screenshot=only-on-failure \
            --output=test-results/

      - name: Upload test artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ matrix.browser }}
          path: test-results/
          retention-days: 7
bashuseful CLI flags for CI
# Always-on CI flags
pytest tests/ \
  --tracing=retain-on-failure \   # save trace only when test fails
  --screenshot=only-on-failure \  # save screenshot only on failure
  --video=retain-on-failure \     # save video only on failure
  --output=./test-results/ \      # artifacts directory
  --junit-xml=results.xml \       # for CI reporting dashboards
  -q                               # quiet output

Pre-Ship Checklist

  • All selectors use get_by_role(), get_by_label(), or get_by_test_id() — no brittle CSS paths
  • No time.sleep() anywhere — all waits use expect() or wait_for_*()
  • Network calls to external APIs mocked via page.route() for determinism
  • Auth state saved with storage_state — re-login happens only once per session
  • Page Object Model in place — selectors defined once, reused everywhere
  • Tracing enabled in CI with --tracing=retain-on-failure
  • Tests parallelised with -n auto via pytest-xdist
  • Tested across all three engines: chromium, firefox, webkit
  • Artifacts (screenshots, traces, videos) uploaded on failure in CI
  • Default timeout set globally via browser_context_args fixture

Anti-Patterns to Avoid

  • Using time.sleep(2) instead of expect(locator).to_be_visible() — causes slow, still-flaky tests
  • Selecting elements by generated class names like .css-1abc2de — breaks on every build
  • Calling page.query_selector() instead of page.locator() — no auto-retry
  • Creating a new browser per test instead of per worker — massive overhead
  • Sharing a single page across all tests without isolation — state leaks between tests
  • Not mocking external APIs — tests become slow and non-deterministic
  • Using XPath for everything — brittle, hard to read, no accessibility alignment
  • Running all tests sequentially in CI — multiply your suite time by browser count
  • Ignoring the trace viewer when debugging failures — it shows every DOM state
  • Storing credentials in test files — use environment variables or GitHub Secrets
AI Skill File

Download playwright-python Skill

This .skill file contains comprehensive reference files covering every aspect of production Playwright automation — selectors, network interception, tracing, POM, pytest fixtures, and CI/CD — ready to load into Claude or any AI tool as expert context for your browser testing questions.

Async & sync API
Network interception
pytest fixtures
Page Object Model
Tracing & debugging
CI/CD workflows
⬇ Download Skill File

Hosted by ZynU Host · host.zynu.net