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_idThe 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()