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.

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 --headed is passed). Use --workers=4 to 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, use page.wait_for_load_state("networkidle") after navigation, and ensure tests don't share browser context state by using the page fixture (function scope) instead of a shared page.