Composing Test Flows from Multiple Page Objects

6 min read

Individual page objects handle single screens. Real user journeys cross multiple screens. This lesson covers how to chain page objects into readable, maintainable end-to-end flows.

The chaining pattern

Each action method returns the page it navigates to. Test functions compose those returns:

def test_complete_purchase_flow(driver):
    order_id = (
        LoginPage(driver)
        .login("standard_user", "secret_sauce")         # → HomePage
        .tap_product("Sauce Labs Backpack")              # → ProductDetailPage
        .add_to_cart()                                   # → ProductDetailPage
        .go_to_cart()                                    # → CartPage
        .proceed_to_checkout()                           # → CheckoutStepOnePage
        .fill_shipping("John", "Doe", "10001")
        .continue_to_review()                            # → CheckoutStepTwoPage
        .finish_checkout()                               # → OrderConfirmationPage
        .get_order_confirmation_text()
    )
    assert "Thank you for your order" in order_id

The test reads like a user journey. Each method's return type is visible in the type hints, so IDEs can autocomplete the next step.

Intermediate assertions

Capture the page object, assert, then continue:

def test_cart_state_before_checkout(driver):
    cart = (
        LoginPage(driver)
        .login("standard_user", "secret_sauce")
        .tap_product("Sauce Labs Backpack")
        .add_to_cart()
        .go_to_cart()
    )
 
    # Assert cart state
    assert cart.get_item_count() == 1
    assert cart.get_subtotal() == "$29.99"
 
    # Continue to checkout
    confirmation = (
        cart.proceed_to_checkout()
        .fill_shipping("John", "Doe", "10001")
        .continue_to_review()
        .finish_checkout()
    )
    assert "Thank you" in confirmation.get_confirmation_text()

Return self for in-place assertions

For verifications that don't navigate away, return self lets the chain continue:

class CartPage(BasePage):
    def assert_item_count(self, expected: int) -> "CartPage":
        actual = self.get_item_count()
        assert actual == expected, f"Expected {expected} items in cart, got {actual}"
        return self  # chain continues
 
    def assert_subtotal(self, expected: str) -> "CartPage":
        actual = self.get_subtotal()
        assert actual == expected, f"Expected subtotal {expected}, got {actual}"
        return self
 
# In test:
(
    cart
    .assert_item_count(1)
    .assert_subtotal("$29.99")
    .proceed_to_checkout()
)

Keep assertion methods to verifiable facts (assert_item_count, assert_subtotal), not generic assertions. Each one should read clearly in the chain.

Shared setup with fixtures

When multiple tests start at the same screen, use a pytest fixture to navigate there:

@pytest.fixture
def cart_with_item(driver):
    """Returns a CartPage with one Backpack already added."""
    return (
        LoginPage(driver)
        .login("standard_user", "secret_sauce")
        .tap_product("Sauce Labs Backpack")
        .add_to_cart()
        .go_to_cart()
    )
 
def test_remove_item_from_cart(cart_with_item):
    cart_with_item.remove_item("Sauce Labs Backpack")
    assert cart_with_item.get_item_count() == 0
 
def test_checkout_from_cart(cart_with_item):
    confirmation = (
        cart_with_item
        .proceed_to_checkout()
        .fill_shipping("John", "Doe", "10001")
        .continue_to_review()
        .finish_checkout()
    )
    assert "Thank you" in confirmation.get_confirmation_text()

Each test gets a fresh cart_with_item because the driver fixture defaults to function scope.

Handling optional interstitials

Some flows have optional interruptions — a "rate us" prompt, a "new feature" tooltip, a push notification request. Handle them transparently in the page object:

class HomePage(BasePage):
    RATING_PROMPT = (AppiumBy.ACCESSIBILITY_ID, "ratingPromptDismiss")
    FEATURE_TOOLTIP = (AppiumBy.ACCESSIBILITY_ID, "featureTooltipClose")
 
    def __init__(self, driver):
        super().__init__(driver)
        self._clear_interstitials()
 
    def _clear_interstitials(self):
        for locator in [self.RATING_PROMPT, self.FEATURE_TOOLTIP]:
            self.dismiss_if_present(locator, timeout=2)

Tests that reach HomePage get a clean state without knowing about the optional dialogs.

Debugging flows

When a flow fails mid-chain, the exception shows the method name and the line in the test, but not which screen the driver was on. Add a debug helper:

class BasePage:
    def debug_current_screen(self) -> str:
        try:
            # Android: current activity
            activity = self.driver.current_activity
            return f"Android: {activity}"
        except Exception:
            pass
        try:
            # iOS: bundle ID
            bundle = self.driver.bundle_id
            return f"iOS: {bundle}"
        except Exception:
            pass
        return "Unknown screen"

Call it in the fixture's teardown when the test failed:

@pytest.fixture
def driver(request):
    d = create_driver()
    yield d
    if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
        print(f"\nDriver on screen: {BasePage(d).debug_current_screen()}")
    d.quit()

// tip to track lessons you complete and pick up where you left off across devices.