Playwright with Python: Complete Testing Guide for 2026
Master Playwright testing with Python. Covers pip install, sync vs async API, locators, auto-waiting, pytest-playwright fixtures, tracing, screenshots, API testing, codegen, and CI/CD integration.
Playwright for Python brings the power of Microsoft's browser automation framework to the Python ecosystem. With auto-waiting, powerful locators, built-in tracing, and first-class pytest integration through pytest-playwright, it offers a modern alternative to Selenium that eliminates entire categories of flaky tests. This guide covers everything you need to build production-grade test suites with Playwright and Python in 2026.
Key Takeaways
- Playwright's auto-waiting eliminates the need for explicit waits, drastically reducing flaky tests compared to Selenium
- The sync API is simpler for most test automation needs, while the async API unlocks performance for advanced use cases
- pytest-playwright provides fixtures for browser, context, and page that handle lifecycle management automatically
- Playwright's trace viewer is the most powerful debugging tool available in any browser automation framework
- Codegen generates working test code by recording your browser interactions, accelerating test creation
- API testing with Playwright's request context lets you combine UI and API tests in a single framework
- Multi-browser testing across Chromium, Firefox, and WebKit requires zero code changes
Setting Up Playwright with Python
Installation
pip install playwright pytest-playwright
playwright install
The playwright install command downloads browser binaries for Chromium, Firefox, and WebKit. If you only need specific browsers:
playwright install chromium
playwright install firefox
Verifying the Installation
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://example.com")
print(f"Title: {page.title()}")
browser.close()
Project Structure
playwright-python-project/
pages/ # Page Object classes
__init__.py
base_page.py
login_page.py
dashboard_page.py
tests/ # Test files
__init__.py
conftest.py # Shared fixtures
test_login.py
test_dashboard.py
test_api.py
data/ # Test data
users.json
pytest.ini # pytest configuration
requirements.txt
For AI coding agents, install a Playwright Python skill for best practices:
npx @qaskills/cli add playwright-python-testing
Sync vs Async API
Playwright for Python provides two APIs. The synchronous API is simpler and recommended for most testing scenarios. The asynchronous API is useful when you need concurrent operations.
Synchronous API
from playwright.sync_api import sync_playwright
def test_homepage():
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com")
assert page.title() == "Example Domain"
browser.close()
Asynchronous API
import asyncio
from playwright.async_api import async_playwright
async def test_homepage_async():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto("https://example.com")
title = await page.title()
assert title == "Example Domain"
await browser.close()
asyncio.run(test_homepage_async())
When to Use Each
Use the sync API for standard test automation. It is easier to read, debug, and maintain. Use the async API when you need to run operations concurrently, such as monitoring multiple pages simultaneously or building a web scraper that processes pages in parallel.
With pytest-playwright, the sync API is the default and integrates seamlessly with pytest fixtures.
Locators and Auto-Waiting
Playwright's locator system is fundamentally different from Selenium's. Locators are lazy -- they do not query the DOM until an action is performed. And every action automatically waits for the element to be actionable (visible, enabled, stable) before proceeding.
Built-in Locators
# By role (recommended -- tests accessibility too)
page.get_by_role("button", name="Submit")
page.get_by_role("heading", name="Welcome")
page.get_by_role("link", name="Learn more")
page.get_by_role("textbox", name="Email")
# By label (for form inputs)
page.get_by_label("Email address")
page.get_by_label("Password")
# By placeholder
page.get_by_placeholder("Enter your email")
# By text
page.get_by_text("Welcome back")
page.get_by_text("Sign up", exact=True)
# By alt text (images)
page.get_by_alt_text("Company logo")
# By title attribute
page.get_by_title("Close dialog")
# By test ID
page.get_by_test_id("submit-button")
page.get_by_test_id("user-card")
CSS and XPath Selectors
# CSS selector
page.locator("[data-testid='product-card']")
page.locator("css=input[type='email']")
# XPath selector
page.locator("xpath=//button[contains(text(), 'Submit')]")
Locator Filtering
# Filter by text
page.get_by_role("listitem").filter(has_text="Python")
# Filter by nested locator
page.get_by_role("listitem").filter(
has=page.get_by_role("button", name="Delete")
)
# Chain locators
page.locator("[data-testid='user-card']").first
page.locator("[data-testid='user-card']").nth(2)
page.locator("[data-testid='user-card']").last
Auto-Waiting in Action
# Playwright automatically waits for the button to be visible,
# enabled, and stable before clicking. No explicit wait needed.
page.get_by_role("button", name="Submit").click()
# For text input, Playwright waits for the field to be visible and editable
page.get_by_label("Email").fill("user@example.com")
# Assertions also auto-wait with configurable timeout
from playwright.sync_api import expect
expect(page.get_by_test_id("success-message")).to_be_visible()
expect(page.get_by_test_id("loading-spinner")).to_be_hidden()
expect(page.get_by_role("heading")).to_have_text("Dashboard")
This auto-waiting behavior is the single biggest improvement over Selenium. You never need to write WebDriverWait or expected_conditions -- Playwright handles it all internally.
pytest-playwright Fixtures
The pytest-playwright plugin provides fixtures that manage the browser lifecycle automatically.
Built-in Fixtures
# tests/test_example.py
def test_basic_navigation(page):
"""The 'page' fixture is provided by pytest-playwright.
It gives you a fresh page in a new browser context."""
page.goto("https://example.com")
assert page.title() == "Example Domain"
def test_with_browser_context(context):
"""The 'context' fixture gives you a browser context.
You can create multiple pages from it."""
page1 = context.new_page()
page2 = context.new_page()
page1.goto("https://example.com")
page2.goto("https://playwright.dev")
assert "Example" in page1.title()
assert "Playwright" in page2.title()
def test_with_browser(browser):
"""The 'browser' fixture gives you the browser instance.
Create contexts with custom settings."""
context = browser.new_context(
viewport={"width": 375, "height": 667},
user_agent="Custom Agent",
)
page = context.new_page()
page.goto("https://example.com")
context.close()
Custom Fixtures in conftest.py
# tests/conftest.py
import pytest
from playwright.sync_api import Page, BrowserContext
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"viewport": {"width": 1920, "height": 1080},
"ignore_https_errors": True,
}
@pytest.fixture
def authenticated_page(page: Page) -> Page:
"""Fixture that provides a page with an authenticated session."""
page.goto("http://localhost:3000/login")
page.get_by_label("Email").fill("test@example.com")
page.get_by_label("Password").fill("password123")
page.get_by_role("button", name="Sign in").click()
page.wait_for_url("**/dashboard")
return page
@pytest.fixture
def mobile_page(browser):
"""Fixture for mobile viewport testing."""
context = browser.new_context(
viewport={"width": 375, "height": 667},
is_mobile=True,
has_touch=True,
)
page = context.new_page()
yield page
context.close()
Running Tests
# Run all tests
pytest tests/ -v
# Run with specific browser
pytest tests/ --browser chromium
pytest tests/ --browser firefox
pytest tests/ --browser webkit
# Run on all browsers
pytest tests/ --browser chromium --browser firefox --browser webkit
# Run headed (visible browser)
pytest tests/ --headed
# Run in slow motion for debugging
pytest tests/ --headed --slowmo 500
Assertions with expect()
Playwright provides a rich assertion library that auto-waits for conditions to be met.
Page Assertions
from playwright.sync_api import expect
# URL assertions
expect(page).to_have_url("https://example.com/dashboard")
expect(page).to_have_url("**/dashboard") # Glob pattern
# Title
expect(page).to_have_title("Dashboard - My App")
expect(page).to_have_title("Dashboard", exact=False)
Locator Assertions
submit_btn = page.get_by_role("button", name="Submit")
# Visibility
expect(submit_btn).to_be_visible()
expect(submit_btn).to_be_hidden()
# Enabled/Disabled
expect(submit_btn).to_be_enabled()
expect(submit_btn).to_be_disabled()
# Text content
expect(page.get_by_test_id("heading")).to_have_text("Welcome")
expect(page.get_by_test_id("heading")).to_contain_text("Welcome")
# Attributes
expect(page.get_by_test_id("input")).to_have_attribute("placeholder", "Enter email")
expect(page.get_by_test_id("link")).to_have_attribute("href", "/about")
# CSS class
expect(page.get_by_test_id("card")).to_have_class("active")
# Count
expect(page.get_by_test_id("list-item")).to_have_count(5)
# Value (for inputs)
expect(page.get_by_label("Name")).to_have_value("John Doe")
# Checked state
expect(page.get_by_role("checkbox")).to_be_checked()
expect(page.get_by_role("checkbox")).not_to_be_checked()
Custom Timeout
# Override the default 5-second timeout for a specific assertion
expect(page.get_by_test_id("slow-content")).to_be_visible(timeout=30000)
Page Object Model with Playwright Python
Base Page
# pages/base_page.py
from playwright.sync_api import Page, expect
class BasePage:
def __init__(self, page: Page):
self.page = page
def navigate(self, path: str):
self.page.goto(f"http://localhost:3000{path}")
def get_title(self) -> str:
return self.page.title()
def wait_for_navigation(self, url_pattern: str):
self.page.wait_for_url(url_pattern)
def take_screenshot(self, name: str):
self.page.screenshot(path=f"screenshots/{name}.png")
Login Page
# pages/login_page.py
from playwright.sync_api import Page, expect
from pages.base_page import BasePage
class LoginPage(BasePage):
def __init__(self, page: Page):
super().__init__(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="Sign in")
self.error_message = page.get_by_test_id("error-message")
def open(self):
self.navigate("/login")
return self
def login(self, email: str, password: str):
self.email_input.fill(email)
self.password_input.fill(password)
self.login_button.click()
return self
def assert_error_visible(self, message: str):
expect(self.error_message).to_be_visible()
expect(self.error_message).to_contain_text(message)
def assert_login_button_disabled(self):
expect(self.login_button).to_be_disabled()
Using Page Objects in Tests
# tests/test_login.py
import pytest
from pages.login_page import LoginPage
class TestLogin:
def test_successful_login(self, page):
login_page = LoginPage(page)
login_page.open().login("admin@example.com", "password123")
page.wait_for_url("**/dashboard")
assert "Dashboard" in page.title()
def test_invalid_credentials(self, page):
login_page = LoginPage(page)
login_page.open().login("wrong@example.com", "wrongpass")
login_page.assert_error_visible("Invalid email or password")
@pytest.mark.parametrize(
"email,password,error",
[
("", "pass123", "Email is required"),
("user@example.com", "", "Password is required"),
],
)
def test_validation(self, page, email, password, error):
login_page = LoginPage(page)
login_page.open().login(email, password)
login_page.assert_error_visible(error)
Tracing and Debugging
Playwright's trace viewer is one of its most valuable features. It records every action, network request, and DOM snapshot during test execution, letting you replay the entire test with full visibility.
Enabling Traces
# In conftest.py
import pytest
from playwright.sync_api import BrowserContext
@pytest.fixture
def context(context: BrowserContext):
context.tracing.start(screenshots=True, snapshots=True, sources=True)
yield context
context.tracing.stop(path="traces/trace.zip")
Viewing Traces
playwright show-trace traces/trace.zip
The trace viewer opens in a browser window showing a timeline of every action, DOM snapshots at each step, network requests and responses, and console logs.
Conditional Tracing (On Failure Only)
@pytest.fixture
def context(context: BrowserContext, request):
context.tracing.start(screenshots=True, snapshots=True)
yield context
if request.node.rep_call and request.node.rep_call.failed:
trace_path = f"traces/{request.node.name}.zip"
context.tracing.stop(path=trace_path)
else:
context.tracing.stop()
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
Screenshots
# Full page screenshot
page.screenshot(path="screenshots/full_page.png", full_page=True)
# Element screenshot
page.get_by_test_id("chart").screenshot(path="screenshots/chart.png")
# Screenshot with clip region
page.screenshot(
path="screenshots/header.png",
clip={"x": 0, "y": 0, "width": 1920, "height": 200},
)
Video Recording
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"record_video_dir": "videos/",
"record_video_size": {"width": 1280, "height": 720},
}
API Testing with Playwright
Playwright includes a built-in API testing client that shares authentication state with browser tests.
Standalone API Tests
from playwright.sync_api import APIRequestContext
def test_create_user(request_context: APIRequestContext):
response = request_context.post(
"http://localhost:3000/api/users",
data={
"name": "Jane Doe",
"email": "jane@example.com",
},
)
assert response.status == 201
body = response.json()
assert body["name"] == "Jane Doe"
assert body["email"] == "jane@example.com"
assert "id" in body
def test_get_users(request_context: APIRequestContext):
response = request_context.get("http://localhost:3000/api/users")
assert response.status == 200
users = response.json()
assert len(users) > 0
def test_delete_user(request_context: APIRequestContext):
# Create a user first
create_response = request_context.post(
"http://localhost:3000/api/users",
data={"name": "Temp User", "email": "temp@example.com"},
)
user_id = create_response.json()["id"]
# Delete the user
delete_response = request_context.delete(
f"http://localhost:3000/api/users/{user_id}"
)
assert delete_response.status == 204
Combining API and UI Tests
def test_api_created_data_appears_in_ui(page, request_context):
# Create data via API
response = request_context.post(
"http://localhost:3000/api/products",
data={"name": "Test Product", "price": 29.99},
)
product_id = response.json()["id"]
# Verify in UI
page.goto("http://localhost:3000/products")
expect(page.get_by_text("Test Product")).to_be_visible()
# Cleanup via API
request_context.delete(f"http://localhost:3000/api/products/{product_id}")
Codegen: Recording Tests
Playwright's codegen tool records your browser interactions and generates working test code.
Using Codegen
# Open codegen targeting your app
playwright codegen http://localhost:3000
# Generate code in Python
playwright codegen --target python http://localhost:3000
# Generate with a specific viewport
playwright codegen --viewport-size "375,667" http://localhost:3000
# Generate with device emulation
playwright codegen --device "iPhone 13" http://localhost:3000
Codegen opens two windows: the browser where you interact with the application, and an inspector window showing the generated code. Every click, type, and navigation is recorded as Playwright Python code that you can copy into your test files.
Tips for Effective Codegen
- Perform the user flow you want to test step by step
- Copy the generated code into a test file
- Replace hardcoded selectors with page object references
- Add meaningful assertions (codegen generates basic ones but you should add more)
- Parameterize test data
Network Interception
# Mock an API response
def test_with_mocked_api(page):
page.route(
"**/api/products",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='[{"id": 1, "name": "Mocked Product", "price": 9.99}]',
),
)
page.goto("http://localhost:3000/products")
expect(page.get_by_text("Mocked Product")).to_be_visible()
# Modify request headers
def test_with_custom_headers(page):
def add_auth_header(route):
headers = {**route.request.headers, "Authorization": "Bearer test-token"}
route.continue_(headers=headers)
page.route("**/*", add_auth_header)
page.goto("http://localhost:3000/dashboard")
# Abort specific requests (block images for faster tests)
def test_without_images(page):
page.route("**/*.{png,jpg,jpeg,gif,svg}", lambda route: route.abort())
page.goto("http://localhost:3000")
# Wait for a specific network response
def test_wait_for_api(page):
with page.expect_response("**/api/data") as response_info:
page.goto("http://localhost:3000/data")
response = response_info.value
assert response.status == 200
data = response.json()
assert len(data) > 0
Multi-Browser and Device Testing
Running Across Browsers
pytest tests/ --browser chromium --browser firefox --browser webkit
Device Emulation
from playwright.sync_api import sync_playwright
def test_mobile_layout():
with sync_playwright() as p:
iphone = p.devices["iPhone 13"]
browser = p.webkit.launch()
context = browser.new_context(**iphone)
page = context.new_page()
page.goto("http://localhost:3000")
# Mobile menu should be visible
expect(page.get_by_test_id("mobile-menu")).to_be_visible()
expect(page.get_by_test_id("desktop-nav")).to_be_hidden()
context.close()
browser.close()
CI/CD Integration
GitHub Actions
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
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 -r requirements.txt
playwright install --with-deps chromium
- name: Start application
run: npm start &
- name: Run tests
run: pytest tests/ -v --browser chromium
- name: Upload traces on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: traces/
- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshots
path: screenshots/
Docker
FROM mcr.microsoft.com/playwright/python:v1.48.0-jammy
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["pytest", "tests/", "-v"]
Advanced Patterns
Authentication State Reuse
# Save authentication state
def test_save_auth_state(page):
page.goto("http://localhost:3000/login")
page.get_by_label("Email").fill("admin@example.com")
page.get_by_label("Password").fill("password123")
page.get_by_role("button", name="Sign in").click()
page.wait_for_url("**/dashboard")
# Save storage state (cookies + localStorage)
page.context.storage_state(path="auth_state.json")
# Reuse in conftest.py
@pytest.fixture
def authenticated_context(browser):
context = browser.new_context(storage_state="auth_state.json")
yield context
context.close()
File Downloads
def test_download_report(page):
with page.expect_download() as download_info:
page.get_by_role("button", name="Download Report").click()
download = download_info.value
assert download.suggested_filename == "report.pdf"
download.save_as(f"downloads/{download.suggested_filename}")
File Uploads
def test_upload_file(page):
page.goto("http://localhost:3000/upload")
# Upload via file input
page.get_by_test_id("file-input").set_input_files("data/sample.pdf")
page.get_by_role("button", name="Upload").click()
expect(page.get_by_text("Upload successful")).to_be_visible()
# Upload multiple files
page.get_by_test_id("file-input").set_input_files([
"data/file1.pdf",
"data/file2.pdf",
])
Parallel Execution
pip install pytest-xdist
pytest tests/ -n 4 --browser chromium
Handling Dialogs
def test_confirm_dialog(page):
# Register dialog handler BEFORE triggering the dialog
page.on("dialog", lambda dialog: dialog.accept())
page.goto("http://localhost:3000/settings")
page.get_by_role("button", name="Delete Account").click()
# The dialog was automatically accepted
expect(page.get_by_text("Account deleted")).to_be_visible()
def test_dismiss_dialog(page):
page.on("dialog", lambda dialog: dialog.dismiss())
page.goto("http://localhost:3000/settings")
page.get_by_role("button", name="Delete Account").click()
# The dialog was dismissed, so the account still exists
expect(page.get_by_test_id("account-settings")).to_be_visible()
def test_prompt_dialog(page):
def handle_prompt(dialog):
assert dialog.type == "prompt"
dialog.accept("My response text")
page.on("dialog", handle_prompt)
page.get_by_role("button", name="Enter Name").click()
Handling iframes
def test_iframe_interaction(page):
page.goto("http://localhost:3000/embedded-content")
# Access iframe by selector
frame = page.frame_locator("[data-testid='payment-iframe']")
# Interact with elements inside the iframe
frame.get_by_label("Card number").fill("4242424242424242")
frame.get_by_label("Expiry").fill("12/28")
frame.get_by_label("CVC").fill("123")
frame.get_by_role("button", name="Pay").click()
# Assert something in the parent page
expect(page.get_by_text("Payment successful")).to_be_visible()
Geolocation and Permissions
def test_geolocation(browser):
context = browser.new_context(
geolocation={"latitude": 40.7128, "longitude": -74.0060},
permissions=["geolocation"],
)
page = context.new_page()
page.goto("http://localhost:3000/store-locator")
page.get_by_role("button", name="Find Nearby Stores").click()
expect(page.get_by_text("New York")).to_be_visible()
context.close()
Data-Driven Testing with Playwright Python
Parameterized tests are essential for covering multiple scenarios without duplicating test code.
import pytest
import json
def load_test_data(filename):
with open(f"data/{filename}") as f:
return json.load(f)
login_scenarios = load_test_data("login_scenarios.json")
@pytest.mark.parametrize(
"scenario",
login_scenarios,
ids=[s["name"] for s in login_scenarios],
)
def test_login_scenarios(page, scenario):
page.goto("http://localhost:3000/login")
page.get_by_label("Email").fill(scenario["email"])
page.get_by_label("Password").fill(scenario["password"])
page.get_by_role("button", name="Sign in").click()
if scenario["expected_result"] == "success":
page.wait_for_url("**/dashboard")
expect(page.get_by_test_id("welcome")).to_be_visible()
else:
expect(page.get_by_test_id("error")).to_contain_text(
scenario["expected_error"]
)
Generating Tests from External Files
import csv
def load_csv_test_data(filepath):
with open(filepath, newline="") as f:
return list(csv.DictReader(f))
search_test_data = load_csv_test_data("data/search_queries.csv")
@pytest.mark.parametrize(
"test_data",
search_test_data,
ids=[d["query"] for d in search_test_data],
)
def test_search_results(page, test_data):
page.goto("http://localhost:3000/search")
page.get_by_role("searchbox").fill(test_data["query"])
page.keyboard.press("Enter")
expected_count = int(test_data["min_results"])
if expected_count == 0:
expect(page.get_by_test_id("no-results")).to_be_visible()
else:
expect(page.get_by_test_id("result-card")).to_have_count(
expected_count, timeout=10000
)
Accessibility Testing with Playwright
Playwright integrates with accessibility testing tools to catch a11y violations automatically.
def test_accessibility_violations(page):
page.goto("http://localhost:3000")
# Use Playwright's built-in accessibility snapshot
snapshot = page.accessibility.snapshot()
assert snapshot is not None
# Check for common issues
# All images should have alt text
images = page.locator("img")
count = images.count()
for i in range(count):
alt = images.nth(i).get_attribute("alt")
assert alt is not None and alt.strip() != "", (
f"Image at index {i} is missing alt text"
)
# All form inputs should have associated labels
inputs = page.locator("input:not([type='hidden'])")
input_count = inputs.count()
for i in range(input_count):
input_el = inputs.nth(i)
input_id = input_el.get_attribute("id")
aria_label = input_el.get_attribute("aria-label")
aria_labelledby = input_el.get_attribute("aria-labelledby")
has_label = (
aria_label
or aria_labelledby
or (input_id and page.locator(f"label[for='{input_id}']").count() > 0)
)
assert has_label, f"Input at index {i} has no accessible label"
For comprehensive a11y testing, combine with the axe-core library:
def test_axe_accessibility(page):
page.goto("http://localhost:3000")
# Inject axe-core and run scan
page.evaluate("""
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js';
document.head.appendChild(script);
""")
page.wait_for_function("typeof axe !== 'undefined'")
results = page.evaluate("axe.run()")
violations = results["violations"]
for violation in violations:
print(f" {violation['id']}: {violation['description']}")
for node in violation["nodes"]:
print(f" - {node['html']}")
assert len(violations) == 0, (
f"Found {len(violations)} accessibility violations"
)
Best Practices Summary
-
Use role-based locators as your primary locator strategy. They test accessibility and are resilient to code changes.
-
Trust auto-waiting. Do not add explicit waits unless you have a very specific reason. Playwright handles timing internally.
-
Use pytest-playwright fixtures. Let the plugin manage browser, context, and page lifecycle.
-
Enable tracing on failure. The trace viewer provides complete test playback including DOM snapshots and network requests.
-
Use codegen for quick test scaffolding. Record interactions and refine the generated code into proper page objects.
-
Combine API and UI tests. Use API calls for test data setup and cleanup, and UI interactions for the flows you are actually testing.
-
Run tests across browsers. Playwright makes cross-browser testing trivial with the
--browserflag. -
Keep tests independent. Each test should create its own state and not depend on other tests.
-
Use storage state for auth. Save and reuse authentication state to avoid repeating login flows.
-
Parallelize with pytest-xdist. Playwright tests are isolated by design and run well in parallel.
Debugging Playwright Tests
Headed Mode with Slow Motion
# Run tests with a visible browser and slow motion
pytest tests/ --headed --slowmo 1000
This opens the browser and adds a 1-second delay between every action, making it easy to follow what the test is doing.
Playwright Inspector
# Launch with Playwright Inspector
PWDEBUG=1 pytest tests/test_login.py -s
The inspector provides step-by-step execution with a "Resume" button, a locator picker that highlights elements on hover, and a console for evaluating locators interactively.
Console Logging
def test_debug_with_logging(page):
page.goto("http://localhost:3000/products")
# Capture console messages
messages = []
page.on("console", lambda msg: messages.append(msg.text))
# Perform actions
page.get_by_role("button", name="Load More").click()
# Check for JavaScript errors
errors = [m for m in messages if "error" in m.lower()]
assert len(errors) == 0, f"Console errors found: {errors}"
Page Error Detection
def test_no_page_errors(page):
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto("http://localhost:3000")
page.get_by_role("link", name="Products").click()
page.get_by_role("link", name="About").click()
assert len(errors) == 0, f"Page errors detected: {errors}"
Enhancing Your AI Agent
Install a Playwright Python skill for your AI coding agent to generate tests that follow best practices:
npx @qaskills/cli add playwright-python-testing
Browse all available QA skills at qaskills.sh/skills.
Conclusion
Playwright with Python is the most productive browser automation stack available in 2026 for teams that want reliability, speed, and cross-browser coverage. Its auto-waiting eliminates flaky tests, pytest-playwright simplifies test lifecycle management, and the trace viewer provides debugging capabilities that no other framework can match.
Start with the patterns in this guide: install Playwright, write tests using role-based locators, organize code with the Page Object Model, and set up CI/CD with trace collection on failure. As your suite grows, add API testing for data setup, use codegen for rapid test creation, and parallelize execution across browsers.
The combination of Python's readability and Playwright's modern automation capabilities creates a testing experience that is both powerful and approachable. Whether you are migrating from Selenium or starting fresh, Playwright Python is a strong choice for building test suites that your team will actually maintain and trust.