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.
See Also
- Python Acceptance Testing Patterns How Python teams verify software does what real users actually asked for.
- Python Approval Testing How approval testing lets you verify complex Python output by comparing it to a saved 'golden' copy you already checked.
- Python Behavior Driven Development Get an intuitive feel for Behavior Driven Development so Python behavior stops feeling unpredictable.
- Python Chaos Testing Applications Why breaking your own Python systems on purpose makes them stronger.
- Python Contract Testing Why contract testing is like having a written agreement between two teams so neither one accidentally breaks the other's work.