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.
supported
async · sync
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.
# 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)
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
# 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 Mode | Import | Best For |
|---|---|---|
| Sync | from playwright.sync_api import sync_playwright | Scripts, pytest, most test suites |
| Async | from playwright.async_api import async_playwright | FastAPI, aiohttp apps, async scraping |
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()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.
# ── 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
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.
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
# 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
# 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.
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
# 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 needed6 — 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.
# 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")# 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 calledDebugging — Inspector & Headed Mode
# 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.
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.
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")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
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
[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.
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# 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(), orget_by_test_id()— no brittle CSS paths - No
time.sleep()anywhere — all waits useexpect()orwait_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 autoviapytest-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_argsfixture
Anti-Patterns to Avoid
- Using
time.sleep(2)instead ofexpect(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 ofpage.locator()— no auto-retry - Creating a new browser per test instead of per worker — massive overhead
- Sharing a single
pageacross 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
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.
Hosted by ZynU Host · host.zynu.net