Designing Mobile Page Classes in Python

6 min read

The Page Object Model structures mobile test code the same way it does web: one class per screen, locators and actions inside, assertions outside. In Python, this maps naturally to classes with tuple-based locator constants and methods that return the next page.

The minimal page object

# pages/login_page.py
from appium.webdriver.common.appiumby import AppiumBy
from pages.base_page import BasePage
 
 
class LoginPage(BasePage):
    # Locator constants
    EMAIL_FIELD    = (AppiumBy.ACCESSIBILITY_ID, "emailInput")
    PASSWORD_FIELD = (AppiumBy.ACCESSIBILITY_ID, "passwordInput")
    LOGIN_BUTTON   = (AppiumBy.ACCESSIBILITY_ID, "loginButton")
    ERROR_BANNER   = (AppiumBy.ACCESSIBILITY_ID, "errorBanner")
 
    def login(self, email: str, password: str) -> "HomePage":
        self.wait_for_clickable(self.EMAIL_FIELD).send_keys(email)
        self.wait_for_clickable(self.PASSWORD_FIELD).send_keys(password)
        self.wait_for_clickable(self.LOGIN_BUTTON).click()
        from pages.home_page import HomePage
        return HomePage(self.driver)
 
    def get_error_message(self) -> str:
        return self.wait_for_visible(self.ERROR_BANNER).text
 
    def is_error_displayed(self) -> bool:
        return self.is_present(self.ERROR_BANNER)

login() returns a HomePage — this enforces the navigation contract in the type hints. Import inside the method to avoid circular imports (Python modules load in order; a top-level import of HomePage in login_page.py would fail if home_page.py imports LoginPage in turn).

Method chaining

Returning page objects from action methods enables readable test chains:

def test_complete_purchase():
    order_id = (
        LoginPage(driver)
        .login("user@example.com", "password")    # → HomePage
        .tap_product("Wireless Headphones")        # → ProductDetailPage
        .add_to_cart()                             # → CartPage
        .proceed_to_checkout()                     # → CheckoutPage
        .fill_shipping("123 Main St", "NY", "10001")
        .place_order()                             # → OrderConfirmationPage
        .get_order_id()
    )
    assert order_id.startswith("ORD-")

Tab navigation:

class HomePage(BasePage):
    PROFILE_TAB = (AppiumBy.ACCESSIBILITY_ID, "profileTab")
 
    def open_profile(self) -> "ProfilePage":
        self.wait_for_clickable(self.PROFILE_TAB).click()
        from pages.profile_page import ProfilePage
        return ProfilePage(self.driver)

Back navigation:

class ProductDetailPage(BasePage):
    def go_back(self) -> "ProductListPage":
        self.driver.back()
        from pages.product_list_page import ProductListPage
        return ProductListPage(self.driver)

Scroll into view then tap:

class MenuPage(BasePage):
    def tap_settings(self) -> "SettingsPage":
        self.driver.find_element(
            AppiumBy.ANDROID_UIAUTOMATOR,
            'new UiScrollable(new UiSelector().scrollable(true))'
            '.scrollIntoView(new UiSelector().text("Settings"))'
        ).click()
        from pages.settings_page import SettingsPage
        return SettingsPage(self.driver)

Optional screen handling

Mobile apps have optional screens: first-run overlays, permission dialogs, rating prompts. Handle them in the page object constructor so tests don't need to know about them:

class HomePage(BasePage):
    WELCOME_DISMISS = (AppiumBy.ACCESSIBILITY_ID, "dismissWelcome")
 
    def __init__(self, driver):
        super().__init__(driver)
        self._dismiss_welcome_if_present()
 
    def _dismiss_welcome_if_present(self):
        from selenium.common.exceptions import TimeoutException
        try:
            element = self.wait_for_clickable_timeout(self.WELCOME_DISMISS, timeout=3)
            element.click()
        except TimeoutException:
            pass  # No overlay — continue

Add wait_for_clickable_timeout to BasePage to support variable timeouts:

def wait_for_clickable_timeout(self, locator, timeout):
    from selenium.webdriver.support.ui import WebDriverWait
    from selenium.webdriver.support import expected_conditions as EC
    return WebDriverWait(self.driver, timeout).until(
        EC.element_to_be_clickable(locator)
    )

Cross-platform page objects

When Android and iOS have different locators for the same screen, use a common interface with platform-specific subclasses:

# pages/login_page.py
class LoginPage(BasePage):
    """Base — override locators in platform subclasses."""
    EMAIL_FIELD: tuple
    PASSWORD_FIELD: tuple
    LOGIN_BUTTON: tuple
 
    def login(self, email: str, password: str):
        self.wait_for_clickable(self.EMAIL_FIELD).send_keys(email)
        self.wait_for_clickable(self.PASSWORD_FIELD).send_keys(password)
        self.wait_for_clickable(self.LOGIN_BUTTON).click()
 
 
class AndroidLoginPage(LoginPage):
    EMAIL_FIELD    = (AppiumBy.ANDROID_UIAUTOMATOR,
                     'new UiSelector().resourceId("com.example.app:id/email")')
    PASSWORD_FIELD = (AppiumBy.ANDROID_UIAUTOMATOR,
                     'new UiSelector().resourceId("com.example.app:id/password")')
    LOGIN_BUTTON   = (AppiumBy.ACCESSIBILITY_ID, "loginButton")
 
 
class IOSLoginPage(LoginPage):
    EMAIL_FIELD    = (AppiumBy.IOS_PREDICATE, "name == 'emailField'")
    PASSWORD_FIELD = (AppiumBy.IOS_PREDICATE, "name == 'passwordField'")
    LOGIN_BUTTON   = (AppiumBy.ACCESSIBILITY_ID, "loginButton")

In the fixture, return the right class based on the platform:

@pytest.fixture
def login_page(driver, platform):
    if platform == "Android":
        return AndroidLoginPage(driver)
    return IOSLoginPage(driver)

What NOT to put in a page object

  • assert statements: Use in tests, not page objects
  • Test data generation: Fixtures or helpers, not page objects
  • Platform detection with if platform ==: Use subclasses instead
  • time.sleep(): Use wait_for_visible / wait_for_clickable instead

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