Browser Automation Testing — Deep Dive

Playwright test infrastructure

A production-grade Playwright setup with pytest uses fixtures for browser lifecycle, authentication state, and test isolation:

# conftest.py
import pytest
from playwright.sync_api import sync_playwright, BrowserContext, Page


@pytest.fixture(scope="session")
def browser():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        yield browser
        browser.close()


@pytest.fixture(scope="session")
def authenticated_state(browser):
    """Login once, save state, reuse across tests."""
    context = browser.new_context()
    page = context.new_page()
    page.goto("http://localhost:3000/login")
    page.fill("#email", "test@example.com")
    page.fill("#password", "testpass123")
    page.click("button[type='submit']")
    page.wait_for_url("**/dashboard")
    state = context.storage_state()
    context.close()
    return state


@pytest.fixture
def auth_page(browser, authenticated_state):
    """Fresh page with pre-authenticated state."""
    context = browser.new_context(storage_state=authenticated_state)
    page = context.new_page()
    yield page
    context.close()


@pytest.fixture
def anon_page(browser):
    """Fresh page without authentication."""
    context = browser.new_context()
    page = context.new_page()
    yield page
    context.close()

The authenticated_state fixture logs in once per session and reuses the cookies and storage, eliminating the login step from every test.

Advanced Page Objects

Extend the basic POM pattern with composition and expected conditions:

# pages/base.py
from playwright.sync_api import Page, expect


class BasePage:
    def __init__(self, page: Page):
        self.page = page

    def navigate(self, path: str = ""):
        url = f"http://localhost:3000{path}"
        self.page.goto(url)
        self.page.wait_for_load_state("networkidle")
        return self

    @property
    def toast_message(self) -> str:
        toast = self.page.locator("[role='alert']")
        toast.wait_for(state="visible", timeout=5000)
        return toast.text_content()


# pages/checkout.py
class CheckoutPage(BasePage):
    def __init__(self, page: Page):
        super().__init__(page)
        self.items_list = page.locator("[data-testid='cart-items']")
        self.subtotal = page.locator("[data-testid='subtotal']")
        self.pay_button = page.locator("[data-testid='pay-now']")
        self.card_frame = page.frame_locator("iframe[name='card-input']")

    def navigate(self, path: str = "/checkout"):
        return super().navigate(path)

    def enter_card(self, number: str, expiry: str, cvc: str):
        self.card_frame.locator("#card-number").fill(number)
        self.card_frame.locator("#expiry").fill(expiry)
        self.card_frame.locator("#cvc").fill(cvc)

    def submit_payment(self):
        self.pay_button.click()
        self.page.wait_for_url("**/confirmation**", timeout=15000)
        return ConfirmationPage(self.page)

    def get_item_count(self) -> int:
        return self.items_list.locator("li").count()

    def get_subtotal(self) -> str:
        return self.subtotal.text_content()


class ConfirmationPage(BasePage):
    @property
    def order_id(self) -> str:
        return self.page.locator("[data-testid='order-id']").text_content()

    @property
    def status(self) -> str:
        return self.page.locator("[data-testid='order-status']").text_content()

Page objects return other page objects when navigation occurs, creating a fluent chain that mirrors actual user flows.

Network mocking and interception

Playwright’s route API lets you mock API responses without touching the backend:

def test_checkout_handles_payment_failure(auth_page):
    """Verify graceful handling when payment API returns an error."""

    def mock_payment_failure(route):
        route.fulfill(
            status=402,
            content_type="application/json",
            body='{"error": "Card declined", "code": "card_declined"}',
        )

    auth_page.route("**/api/payments/charge", mock_payment_failure)

    checkout = CheckoutPage(auth_page).navigate()
    checkout.enter_card("4000000000000002", "12/28", "123")
    checkout.pay_button.click()

    expect(auth_page.locator(".error-banner")).to_contain_text("Card declined")
    expect(auth_page).to_have_url("**/checkout")  # Should stay on checkout


def test_slow_api_shows_loading_state(auth_page):
    """Verify loading spinner appears during slow API calls."""

    def slow_response(route):
        import time
        time.sleep(3)
        route.fulfill(
            status=200,
            content_type="application/json",
            body='{"products": []}',
        )

    auth_page.route("**/api/products*", slow_response)
    auth_page.goto("http://localhost:3000/products")

    # Loading state should be visible immediately
    expect(auth_page.locator("[data-testid='loading-spinner']")).to_be_visible()

Tracing and debugging

Playwright’s trace viewer records every action, screenshot, and network request for debugging failed tests:

@pytest.fixture
def traced_page(browser):
    context = browser.new_context()
    context.tracing.start(screenshots=True, snapshots=True, sources=True)
    page = context.new_page()
    yield page
    context.tracing.stop(path="traces/trace.zip")
    context.close()

View traces with:

playwright show-trace traces/trace.zip

This opens an interactive timeline showing exactly what the browser did at each step — invaluable for debugging tests that pass locally but fail in CI.

For CI, save traces only on failure:

@pytest.fixture
def page(browser, request):
    context = browser.new_context()
    context.tracing.start(screenshots=True, snapshots=True)
    page = context.new_page()
    yield page
    if request.node.rep_call and request.node.rep_call.failed:
        trace_name = request.node.name.replace("/", "_")
        context.tracing.stop(path=f"traces/{trace_name}.zip")
    else:
        context.tracing.stop()
    context.close()


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    rep = outcome.get_result()
    setattr(item, f"rep_{rep.when}", rep)

Parallel execution

Browser tests are slow. Run them in parallel with pytest-xdist:

pytest tests/browser/ -n 4 --dist=loadgroup

Use @pytest.mark.xdist_group to keep related tests on the same worker:

@pytest.mark.xdist_group("checkout")
class TestCheckoutFlow:
    def test_add_to_cart(self, auth_page):
        pass

    def test_apply_coupon(self, auth_page):
        pass

    def test_complete_purchase(self, auth_page):
        pass

Each worker gets its own browser instance. Four workers typically achieve 3–3.5x speedup (not quite linear due to resource contention).

Cross-browser testing

Test across browsers using parameterized fixtures:

@pytest.fixture(params=["chromium", "firefox", "webkit"])
def cross_browser_page(request):
    with sync_playwright() as p:
        browser_type = getattr(p, request.param)
        browser = browser_type.launch(headless=True)
        page = browser.new_page()
        yield page
        browser.close()


def test_login_cross_browser(cross_browser_page):
    page = cross_browser_page
    page.goto("http://localhost:3000/login")
    page.fill("#email", "user@example.com")
    page.fill("#password", "password123")
    page.click("button[type='submit']")
    expect(page).to_have_url("**/dashboard")

Run critical user journeys across all browsers; run the full suite on Chromium only for speed.

CI pipeline integration

# .github/workflows/browser-tests.yml
jobs:
  browser-tests:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright/python:v1.42.0
    services:
      app:
        image: myapp:latest
        ports:
          - 3000:3000
    steps:
      - uses: actions/checkout@v4
      - run: pip install -e ".[test]"

      - name: Run browser tests
        run: pytest tests/browser/ -n 4 --timeout=60
        env:
          BASE_URL: http://app:3000

      - name: Upload traces on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-traces
          path: traces/
          retention-days: 7

Accessibility testing in browser context

Leverage the browser to test accessibility alongside functionality:

from playwright.sync_api import Page


def test_dashboard_accessibility(auth_page: Page):
    auth_page.goto("http://localhost:3000/dashboard")

    # Check for ARIA landmarks
    expect(auth_page.locator("main")).to_be_visible()
    expect(auth_page.locator("nav")).to_be_visible()

    # Verify keyboard navigation
    auth_page.keyboard.press("Tab")
    focused = auth_page.evaluate("document.activeElement.tagName")
    assert focused in ("A", "BUTTON", "INPUT")

    # Run axe accessibility audit
    auth_page.evaluate("""
        const script = document.createElement('script');
        script.src = 'https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js';
        document.head.appendChild(script);
    """)
    auth_page.wait_for_function("typeof axe !== 'undefined'")
    results = auth_page.evaluate("axe.run()")
    violations = results["violations"]
    assert len(violations) == 0, (
        f"Accessibility violations: "
        + "\n".join(v["description"] for v in violations)
    )

The one thing to remember: Production browser test suites combine reusable authentication state, Page Objects for maintainability, network mocking for reliability, parallel execution for speed, trace recording for debugging, and CI integration that captures artifacts on failure.

pythontestingautomation

See Also