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 instanceIn 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