E2E Framework Design

Architecting a maintainable E2E test framework — the code behind the tests.

Architecting a maintainable E2E test framework. The code behind the tests.


Framework Layering

Layer 4: Tests              test_checkout.py, test_login.py
                            Arrange-Act-Assert; no low-level browser calls
                            ↑ reads from
Layer 3: Journeys/Flows     CheckoutJourney, AuthJourney
                            Multi-step user flows; composes page objects
                            ↑ reads from
Layer 2: Page Objects       CheckoutPage, CartPage, LoginPage
                            Encapsulates selectors and interactions per page
                            ↑ reads from
Layer 1: Component Objects  ProductCard, PriceDisplay, FormField
                            Reusable UI components that appear on multiple pages
                            ↑ reads from
Layer 0: Browser Abstraction Playwright Page, BrowserContext
                            Raw browser; never accessed directly in tests

Rule: each layer only imports from the layer directly below it.
Tests never touch selectors. Component objects never know about journeys.

Page Object Pattern

# pages/checkout_page.py
from playwright.sync_api import Page, expect
from .base_page import BasePage

class CheckoutPage(BasePage):
    PATH = "/checkout"

    def __init__(self, page: Page) -> None:
        super().__init__(page)
        # Locators — named for what they mean, not CSS classes
        self._email = page.get_by_label("Email address")
        self._name = page.get_by_label("Full name")
        self._card_number = page.get_by_label("Card number")
        self._card_expiry = page.get_by_label("Expiry date")
        self._card_cvc = page.get_by_label("CVC")
        self._place_order_btn = page.get_by_role("button", name="Place order")
        self._discount_input = page.get_by_label("Discount code")
        self._apply_discount_btn = page.get_by_role("button", name="Apply")
        self._order_total = page.get_by_test_id("order-total")
        self._error_message = page.get_by_role("alert")

    def fill_payment_details(
        self,
        email: str = "buyer@example.com",
        name: str = "Test Buyer",
        card: str = "4242424242424242",
        expiry: str = "12/28",
        cvc: str = "123",
    ) -> "CheckoutPage":
        self._email.fill(email)
        self._name.fill(name)
        self._card_number.fill(card)
        self._card_expiry.fill(expiry)
        self._card_cvc.fill(cvc)
        return self   # fluent interface for chaining

    def apply_discount(self, code: str) -> "CheckoutPage":
        self._discount_input.fill(code)
        self._apply_discount_btn.click()
        return self

    def place_order(self) -> "OrderConfirmationPage":
        self._place_order_btn.click()
        from .order_confirmation_page import OrderConfirmationPage
        return OrderConfirmationPage(self._page)

    def get_total(self) -> float:
        text = self._order_total.inner_text()
        return float(text.replace("£", "").replace(",", "").strip())

    def expect_error(self, message: str) -> None:
        expect(self._error_message).to_contain_text(message)

    def expect_discount_applied(self) -> None:
        expect(self._page.get_by_text("Discount applied")).to_be_visible()
# pages/base_page.py
from playwright.sync_api import Page, expect

class BasePage:
    def __init__(self, page: Page) -> None:
        self._page = page

    def navigate(self) -> "BasePage":
        self._page.goto(self.PATH)
        return self

    def expect_loaded(self) -> "BasePage":
        expect(self._page).to_have_url(f"**{self.PATH}**")
        return self

    def screenshot(self, name: str) -> None:
        self._page.screenshot(path=f"screenshots/{name}.png", full_page=True)

Journey Pattern (Flow Composition)

# journeys/checkout_journey.py
from pages.cart_page import CartPage
from pages.checkout_page import CheckoutPage
from pages.order_confirmation_page import OrderConfirmationPage

class CheckoutJourney:
    """High-level user journey that composes multiple page objects."""

    def __init__(self, page) -> None:
        self._page = page

    def complete_as_guest(
        self,
        product_slug: str = "widget-pro",
        discount_code: str | None = None,
    ) -> OrderConfirmationPage:
        # Navigate to product and add to cart
        self._page.goto(f"/products/{product_slug}")
        self._page.get_by_role("button", name="Add to cart").click()

        # Go to checkout
        CartPage(self._page).proceed_to_checkout()

        # Fill and submit
        checkout = CheckoutPage(self._page).fill_payment_details()
        if discount_code:
            checkout.apply_discount(discount_code)
        return checkout.place_order()

    def complete_as_authenticated(self, user_token: str, **kwargs) -> OrderConfirmationPage:
        self._page.context.add_cookies([{
            "name": "auth_token", "value": user_token,
            "domain": "localhost", "path": "/",
        }])
        return self.complete_as_guest(**kwargs)

Test Layer (Thin and Declarative)

# tests/e2e/test_checkout.py
import pytest
from journeys.checkout_journey import CheckoutJourney
from playwright.sync_api import Page, expect

class TestGuestCheckout:
    def test_complete_checkout(self, page: Page, base_url: str) -> None:
        confirmation = CheckoutJourney(page).complete_as_guest()
        expect(page).to_have_url(re.compile(r"/orders/.+/confirmation"))
        expect(page.get_by_role("heading", name="Order confirmed")).to_be_visible()

    def test_discount_code_reduces_total(self, page: Page) -> None:
        journey = CheckoutJourney(page)
        checkout = CheckoutPage(page).navigate().fill_payment_details()
        original_total = checkout.get_total()
        checkout.apply_discount("SAVE10").expect_discount_applied()
        assert checkout.get_total() < original_total

    def test_declined_card_shows_error(self, page: Page) -> None:
        CheckoutPage(page).navigate().fill_payment_details(card="4000000000000002").place_order()
        CheckoutPage(page).expect_error("Your card was declined")

Fixture Design for E2E

# conftest.py
import pytest
from playwright.sync_api import BrowserContext

@pytest.fixture(scope="session")
def base_url() -> str:
    return "http://localhost:3000"

@pytest.fixture(scope="session")
def api_url() -> str:
    return "http://localhost:8000"

@pytest.fixture
def page(context: BrowserContext, base_url: str):
    """Fresh page with base URL pre-set."""
    page = context.new_page()
    page.goto(base_url)
    yield page
    # Capture screenshot on failure (pytest hook handles this automatically)
    page.close()

@pytest.fixture(scope="session")
def admin_token(api_url: str) -> str:
    """Session-scoped admin token — created once, reused."""
    import httpx
    r = httpx.post(f"{api_url}/auth/token",
                   json={"email": "admin@test.com", "password": "AdminPass!"})
    return r.json()["access_token"]

@pytest.fixture
def test_product(api_url: str, admin_token: str) -> dict:
    """Create a product for the test, delete after."""
    import httpx, uuid
    headers = {"Authorization": f"Bearer {admin_token}"}
    r = httpx.post(f"{api_url}/products",
                   json={"name": f"Test Product {uuid.uuid4()}", "price": 49.99},
                   headers=headers)
    product = r.json()
    yield product
    httpx.delete(f"{api_url}/products/{product['id']}", headers=headers)

Framework Decisions

When to use Page Objects vs plain functions:
  Page Objects: when a "page" has multiple interactions and assertions
  Plain functions: one-off helpers; login_via_api() doesn't need a class

When to use Journeys:
  When the same multi-step flow appears in multiple test files.
  Don't journey-ise unique flows — that's premature abstraction.

Selectors — priority order (most to least resilient):
  1. getByRole("button", name="Submit")    ← semantics; survives CSS rewrites
  2. getByLabel("Email address")           ← form accessibility; very stable
  3. getByText("Place order")              ← visible text; okay for unique text
  4. getByTestId("order-total")            ← test-only attribute; explicit contract
  5. CSS/XPath                             ← last resort; fragile

Never hardcode: data-test-id values that change, CSS class names, element position

Framework anti-patterns:
  - Tests that know about database schema (integration test responsibility)
  - Page objects that navigate to other pages (fragile coupling)
  - Base page with 20+ methods (becomes a dumping ground)
  - Shared user between test files (state corruption under parallel runs)

Common Failure Cases

Layer violation — tests reach through the abstraction Why: tests import page object internals or directly call page.locator() when page objects exist, coupling tests to DOM structure. Detect: grep the test layer for page.locator, page.fill, or CSS selectors that should live in layer 1 or 2. Fix: move every selector and low-level interaction into the appropriate page object or component object.

Over-abstracted journey for a one-off flow Why: developers journey-ise a flow used in only one test file, adding indirection with no reuse benefit. Detect: a journey class referenced from exactly one test file. Fix: inline the flow back into the test or a simple helper function.

Shared user state across parallel test files Why: fixtures use a single hardcoded test account, so two parallel workers overwrite each other's session or cart state. Detect: tests pass in serial but fail intermittently under -n auto; failure messages reference wrong data. Fix: create per-test or per-worker user accounts via the API fixture at the appropriate scope.

Locators instantiated at import time instead of call time Why: page object locators stored as attributes before page.goto() is called can resolve against the wrong DOM or throw if the context is not ready. Detect: TimeoutError on the first interaction of a test that looks like it should work. Fix: store the locator references in __init__ after calling goto, or use lazy property evaluation.

Page object navigates to a different page on action Why: place_order() returns a new page object by importing it internally, tightly coupling two page objects. Detect: cross-file circular imports, or refactoring one page requiring changes in unrelated pages. Fix: return only the new page object from the method; the caller owns navigation logic, not the current page.

Connections

tqa-hub · technical-qa/playwright-advanced · technical-qa/test-architecture · technical-qa/parallel-test-execution · qa/end-to-end-testing · technical-qa/pytest-advanced

Open Questions

  • What is the most common failure mode when implementing this at scale?
  • How does this testing approach need to adapt for distributed or microservice architectures?