Using Python Properties for Lazy Element Access

5 min read

Page objects carry locators. Tests carry data. Keeping these two concerns separate makes test suites easier to maintain — change a locator without touching data, change test data without touching page objects.

Dataclasses for typed test data

Python's @dataclass gives structured test data with type hints and sensible defaults:

# utils/test_data.py
from dataclasses import dataclass, field
from typing import Optional
 
 
@dataclass
class LoginCredentials:
    username: str
    password: str
    expect_success: bool = True
    expected_error: Optional[str] = None
 
    def __str__(self) -> str:
        status = "valid" if self.expect_success else "invalid"
        return f"{status} login [{self.username}]"
 
 
@dataclass
class ShippingAddress:
    first_name: str = "Test"
    last_name: str = "User"
    zip_code: str = "10001"
    city: str = "New York"
    state: str = "NY"

In tests:

VALID_USER = LoginCredentials(username="standard_user", password="secret_sauce")
LOCKED_USER = LoginCredentials(
    username="locked_out_user",
    password="secret_sauce",
    expect_success=False,
    expected_error="Sorry, this user has been locked out"
)

Loading test data from JSON

For larger data sets, read from JSON files rather than hardcoding in test files:

[
  {
    "username": "standard_user",
    "password": "secret_sauce",
    "expect_success": true
  },
  {
    "username": "locked_out_user",
    "password": "secret_sauce",
    "expect_success": false,
    "expected_error": "Sorry, this user has been locked out"
  }
]
import json
from pathlib import Path
 
def load_login_data() -> list[LoginCredentials]:
    data_file = Path("tests/testdata/login_credentials.json")
    raw = json.loads(data_file.read_text())
    return [LoginCredentials(**item) for item in raw]

Properties for computed locators

When locators need to be constructed dynamically (e.g., selecting a product by name from a list):

class ProductListPage(BasePage):
    PRODUCT_ITEMS = (AppiumBy.ACCESSIBILITY_ID, "productItem")
 
    @staticmethod
    def product_by_name(name: str) -> tuple:
        """Returns a locator for a specific product by name."""
        return (
            AppiumBy.ANDROID_UIAUTOMATOR,
            f'new UiSelector().text("{name}")'
        )
 
    def tap_product(self, name: str) -> "ProductDetailPage":
        self.wait_for_clickable(self.product_by_name(name)).click()
        from pages.product_detail_page import ProductDetailPage
        return ProductDetailPage(self.driver)

Configuration via Python properties

Expose configuration values through properties to keep the access pattern consistent:

# utils/config.py
import os
from pathlib import Path
 
 
class Config:
    @property
    def appium_url(self) -> str:
        return os.getenv("APPIUM_SERVER", "http://127.0.0.1:4723")
 
    @property
    def platform(self) -> str:
        return os.getenv("PLATFORM", "Android")
 
    @property
    def android_device(self) -> str:
        return os.getenv("ANDROID_DEVICE", "emulator-5554")
 
    @property
    def ios_device(self) -> str:
        return os.getenv("IOS_DEVICE", "iPhone 15")
 
    @property
    def android_app_path(self) -> str:
        path = os.getenv("APP_PATH_ANDROID", "apps/app-debug.apk")
        return str(Path(path).resolve())
 
    @property
    def ios_app_path(self) -> str:
        path = os.getenv("APP_PATH_IOS", "apps/MyApp.app")
        return str(Path(path).resolve())
 
 
config = Config()  # singleton instance

In fixtures:

from utils.config import config
 
@pytest.fixture
def driver():
    if config.platform == "Android":
        options = UiAutomator2Options()
        options.device_name = config.android_device
        options.app = config.android_app_path
    else:
        options = XCUITestOptions()
        options.device_name = config.ios_device
        options.app = config.ios_app_path
 
    d = webdriver.Remote(config.appium_url, options=options)
    yield d
    d.quit()

NamedTuple for immutable locators

If you want locators to be strictly immutable and hashable (useful for caching):

from typing import NamedTuple
 
class Locator(NamedTuple):
    by: str
    value: str
 
class LoginPage(BasePage):
    EMAIL = Locator(AppiumBy.ACCESSIBILITY_ID, "emailInput")
    PASSWORD = Locator(AppiumBy.ACCESSIBILITY_ID, "passwordInput")
    LOGIN = Locator(AppiumBy.ACCESSIBILITY_ID, "loginButton")
 
    def login(self, email: str, password: str):
        self.driver.find_element(*self.EMAIL).send_keys(email)
        self.driver.find_element(*self.PASSWORD).send_keys(password)
        self.driver.find_element(*self.LOGIN).click()

NamedTuple fields unpack cleanly with *self.EMAIL because Locator is a tuple subclass.

Environment-specific test data

Some test data varies by environment (staging vs production):

class TestUsers:
    STAGING = LoginCredentials("standard_user", "secret_sauce")
    PRODUCTION = LoginCredentials(
        os.getenv("PROD_USERNAME"), os.getenv("PROD_PASSWORD")
    )
 
    @classmethod
    def for_current_env(cls) -> LoginCredentials:
        env = os.getenv("TEST_ENV", "staging")
        return cls.STAGING if env == "staging" else cls.PRODUCTION

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