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-")Navigation patterns
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 — continueAdd 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(): Usewait_for_visible/wait_for_clickableinstead