Python Playwright Testing: Browser Automation Guide
Playwright is Microsoft's modern browser automation library that replaces Selenium for end-to-end testing. It supports Chromium, Firefox, and WebKit from a single API, runs tests in parallel across browsers, auto-waits for elements to be ready before interacting, and includes built-in network mocking, screenshot diffing, and a powerful locator system based on ARIA roles and text. The Python SDK integrates directly with pytest via pytest-playwright.
Table of Contents
Installation and First Test
pip install playwright pytest-playwright
playwright install chromium # download browser binaries
# Or install all browsers:
playwright install
# test_basic.py — sync API via pytest-playwright
def test_homepage_title(page):
page.goto("https://techoral.com")
assert "Techoral" in page.title()
def test_search_form(page):
page.goto("https://techoral.com")
page.get_by_placeholder("Search...").fill("Python FastAPI")
page.get_by_role("button", name="Search").click()
page.wait_for_url("**/search**")
assert page.locator(".search-result").count() > 0
# Run tests
pytest test_basic.py -v
# Run headed (see the browser)
pytest test_basic.py --headed
# Run on specific browser
pytest test_basic.py --browser firefox
# Run on all browsers in parallel
pytest test_basic.py --browser chromium --browser firefox --browser webkit
# Run with trace recording for debugging
pytest test_basic.py --tracing on
Locators: Finding Elements
Playwright's locator system is fundamentally different from Selenium's. Locators are lazy — they don't interact with the DOM until you call an action, and they auto-retry until the element is visible, enabled, and stable. Always prefer user-facing locators (role, text, label) over CSS selectors or XPath.
from playwright.sync_api import Page
def test_locator_strategies(page: Page):
page.goto("https://example.com/dashboard")
# Preferred: ARIA role + accessible name
page.get_by_role("button", name="Submit Order").click()
page.get_by_role("link", name="Home").click()
page.get_by_role("heading", name="Dashboard").is_visible()
page.get_by_role("checkbox", name="Remember me").check()
# By label text (matches form input by its label)
page.get_by_label("Email address").fill("user@example.com")
page.get_by_label("Password").fill("secret123")
# By placeholder text
page.get_by_placeholder("Search products...").fill("laptop")
# By visible text content
page.get_by_text("Terms and Conditions").click()
# By alt text (images)
page.get_by_alt_text("Company logo").is_visible()
# By test ID (data-testid attribute — good fallback)
page.get_by_test_id("submit-button").click()
# CSS selector — use only when above options don't fit
page.locator(".nav-item.active").first.click()
page.locator("table tbody tr").nth(2).click()
# Chain locators — scope search within a parent
sidebar = page.locator(".sidebar")
sidebar.get_by_role("link", name="Settings").click()
# Filter locators
page.locator(".product-card").filter(has_text="In Stock").first.click()
Page Interactions
Playwright interactions auto-wait for elements to be ready — visible, enabled, and stable — before acting. This eliminates the time.sleep() calls that plague Selenium tests. For file inputs, dialogs, and keyboard shortcuts, Playwright provides first-class support.
from playwright.sync_api import Page, expect
def test_checkout_flow(page: Page):
page.goto("https://shop.example.com")
# Form interactions
page.get_by_label("Email").fill("user@example.com")
page.get_by_label("Password").fill("password123")
page.get_by_role("button", name="Sign In").click()
# Navigation
page.wait_for_url("**/dashboard**")
# Add item to cart
page.get_by_role("button", name="Add to Cart").first.click()
# Select from dropdown
page.get_by_label("Quantity").select_option("3")
# Checkbox and radio
page.get_by_label("Gift wrap").check()
page.get_by_label("Standard shipping").check()
# File upload
page.get_by_label("Upload receipt").set_input_files("/tmp/receipt.pdf")
# Keyboard interaction
page.get_by_label("Discount code").fill("SAVE20")
page.keyboard.press("Enter")
# Handle browser dialog
page.on("dialog", lambda dialog: dialog.accept())
page.get_by_role("button", name="Delete item").click()
# Hover to reveal tooltip/menu
page.get_by_role("button", name="Account").hover()
page.get_by_role("menuitem", name="Sign Out").click()
# Take screenshot at a specific moment
page.screenshot(path="/tmp/after-checkout.png", full_page=True)
Auto-Waiting Assertions
Use expect() from playwright.sync_api for assertions. Unlike regular Python assertions, expect() auto-retries for up to 5 seconds by default — no time.sleep() needed while waiting for async UI updates.
from playwright.sync_api import Page, expect
def test_form_validation(page: Page):
page.goto("https://example.com/register")
# Text content assertions
expect(page.get_by_role("heading", name="Register")).to_be_visible()
expect(page.locator(".error-message")).to_have_text("Email is required")
expect(page.locator(".success-banner")).to_contain_text("Account created")
# Attribute assertions
expect(page.get_by_role("button", name="Submit")).to_be_enabled()
expect(page.get_by_role("button", name="Submit")).to_have_attribute("type", "submit")
expect(page.get_by_label("Email")).to_have_value("user@example.com")
# Visibility and count
expect(page.locator(".product-card")).to_have_count(12)
expect(page.locator(".loading-spinner")).not_to_be_visible()
# URL and title
expect(page).to_have_url("https://example.com/dashboard")
expect(page).to_have_title("Dashboard — Techoral")
# Wait with custom timeout
expect(page.locator(".data-table")).to_be_visible(timeout=10_000) # 10 seconds
Network Interception and Mocking
Playwright can intercept, modify, and mock HTTP requests. This is essential for testing error states, slow networks, and scenarios that are hard to reproduce with a real backend.
from playwright.sync_api import Page, Route
def test_api_error_handling(page: Page):
"""Test that the UI handles a 500 error gracefully."""
def mock_500(route: Route):
route.fulfill(status=500, json={"error": "Internal Server Error"})
page.route("**/api/orders**", mock_500)
page.goto("https://example.com/orders")
expect(page.locator(".error-state")).to_be_visible()
expect(page.locator(".error-state")).to_contain_text("Something went wrong")
def test_slow_network(page: Page):
"""Simulate slow API response."""
import time
def delay_response(route: Route):
time.sleep(2) # 2 second delay
route.continue_()
page.route("**/api/**", delay_response)
page.goto("https://example.com/dashboard")
expect(page.locator(".loading-skeleton")).to_be_visible()
expect(page.locator(".data-table")).to_be_visible(timeout=10_000)
def test_mock_search_results(page: Page):
"""Return fake search results to keep tests deterministic."""
page.route("**/api/search*", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='[{"id": 1, "title": "FastAPI Guide"}, {"id": 2, "title": "Python Async"}]',
))
page.goto("https://example.com/search?q=python")
expect(page.locator(".search-result")).to_have_count(2)
expect(page.locator(".search-result").first).to_contain_text("FastAPI Guide")
pytest-playwright Fixtures
pytest-playwright provides browser, context, and page fixtures with configurable scope. Override them in conftest.py to set a base URL, viewport size, authentication state, or storage state (pre-logged-in cookies).
# conftest.py
import pytest
from playwright.sync_api import BrowserContext, Page
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
"""Override default context settings for all tests."""
return {
**browser_context_args,
"viewport": {"width": 1440, "height": 900},
"locale": "en-US",
"timezone_id": "Asia/Kolkata",
"record_video_dir": "/tmp/playwright-videos/",
}
@pytest.fixture(scope="session")
def authenticated_context(browser):
"""Create a pre-authenticated browser context saved to storage_state."""
context = browser.new_context(base_url="https://staging.techoral.com")
page = context.new_page()
page.goto("/login")
page.get_by_label("Email").fill("test@techoral.com")
page.get_by_label("Password").fill("test-password")
page.get_by_role("button", name="Sign In").click()
page.wait_for_url("**/dashboard**")
# Save cookies and localStorage for reuse
context.storage_state(path="/tmp/auth-state.json")
yield context
context.close()
@pytest.fixture()
def auth_page(authenticated_context) -> Page:
"""Page that is already authenticated."""
page = authenticated_context.new_page()
yield page
page.close()
# pytest.ini or pyproject.toml
# [tool.pytest.ini_options]
# base_url = "https://staging.techoral.com"
Page Object Model
The Page Object Model (POM) encapsulates page-specific locators and actions into reusable classes, keeping test logic separate from UI navigation details. When a UI changes, you update only the page object, not every test.
from playwright.sync_api import Page, expect
class LoginPage:
def __init__(self, page: Page):
self.page = page
self.email_input = page.get_by_label("Email")
self.password_input = page.get_by_label("Password")
self.submit_button = page.get_by_role("button", name="Sign In")
self.error_message = page.locator(".login-error")
def goto(self):
self.page.goto("/login")
return self
def login(self, email: str, password: str) -> "DashboardPage":
self.email_input.fill(email)
self.password_input.fill(password)
self.submit_button.click()
return DashboardPage(self.page)
def expect_error(self, message: str):
expect(self.error_message).to_have_text(message)
class DashboardPage:
def __init__(self, page: Page):
self.page = page
self.heading = page.get_by_role("heading", name="Dashboard")
self.orders_link = page.get_by_role("link", name="Orders")
def expect_loaded(self):
expect(self.page).to_have_url("**/dashboard**")
expect(self.heading).to_be_visible()
return self
def go_to_orders(self) -> "OrdersPage":
self.orders_link.click()
return OrdersPage(self.page)
class OrdersPage:
def __init__(self, page: Page):
self.page = page
self.order_rows = page.locator("table tbody tr")
# Usage in tests
def test_successful_login(page: Page):
dashboard = LoginPage(page).goto().login("user@example.com", "password123")
dashboard.expect_loaded()
def test_failed_login(page: Page):
login = LoginPage(page).goto()
login.login("user@example.com", "wrong-password")
login.expect_error("Invalid credentials")
Frequently Asked Questions
- Playwright vs Selenium — which should I use?
- Playwright is the modern choice. It auto-waits for elements, has a much faster and more reliable API, supports network mocking natively, and generates traces for debugging. Selenium has broader language support and legacy tool integrations, but for new Python projects Playwright is strongly preferred.
- How do I run Playwright tests in CI?
- Install browsers in CI with
playwright install --with-deps chromium. Run headless by default (Playwright is headless unless--headedis passed). Use--workers=4to run tests in parallel. Store traces and videos on failure with--tracing=retain-on-failure. - How do I handle flaky tests?
- Playwright's auto-wait eliminates most flakiness. For remaining flaky tests: use
expect(locator).to_be_visible(timeout=10_000)instead of fixed sleeps, usepage.wait_for_load_state("networkidle")after navigation, and ensure tests don't share browser context state by using thepagefixture (function scope) instead of a shared page.