NashTech Blog

Table of Contents

This post presents a sustainable Python Appium design: an app-agnostic core/ for config, sessions, elements, and reporting, with pages, features, and test data kept in a thin suite on top. It covers folder layout, layering, capability profiles, cross-platform locators, a suggested build order, and the full core/ source so you can recreate the foundation in your own project.

What you need

  • Python 3.10+ (this project pins 3.11)
  • uv for deps + lockfile
  • Appium 2.x / 3.x with uiautomator2 and (on macOS) xcuitest
  • Emulator / simulator / real device, or a BrowserStack account
npm install -g appium
appium driver install uiautomator2
appium driver install xcuitest   # macOS / iOS
appium                           # http://127.0.0.1:4723

curl -LsSf https://astral.sh/uv/install.sh | sh
uv sync
uv run behave -D profile=emulator

Key Python packages: Appium-Python-Client (≥5), selenium (≥4.25), behaveallure-behaveglom.


The idea: core/ vs your suite

core knows Appium, Selenium, and config. It does not know your screens, features, or test accounts.

core/
├── configuration/config_reader.py
├── driver/
│   ├── driver_factory.py
│   ├── driver_manager.py
│   ├── driver_helper.py
│   └── platforms/
│       ├── android_driver.py
│       └── ios_driver.py
├── element/
│   ├── element.py
│   ├── locator.py
│   ├── strategies.py
│   └── annotations.py
├── report/allurereport.py
└── util/jsonutil.py

Android ↔ iOS ↔ cloud is a profile string, not a fork of page objects.


How the layers fit

profile string  (e.g. "emulator.ios")
        │
        ▼
┌───────────────────┐
│   ConfigReader    │  profile → env $VARS → app path
└─────────┬─────────┘
          │  capabilities + appium_server_url
          ▼
┌───────────────────┐
│   DriverFactory   │  platformName → Android / iOS Options
└─────────┬─────────┘
          ▼
┌───────────────────┐
│   DriverManager   │  initialize / quit (shared session)
└─────────┬─────────┘
          ▼
┌───────────────────┐
│ Element + Locator │  waits, tap/type, gestures
└───────────────────┘
          ▲
   Your page objects (outside core)
  • Configuration — environments and secrets stay in JSON + env vars
  • Driver factory / platforms — Appium 2 Options API only; no test logic
  • Driver manager — one session per feature; pages never call Remote
  • Element / Locator — one interaction API; platform differences stay inside

Capability profiles

config.json is the control panel. Path comes from APPIUM_BDD_CONFIG or the ConfigReader constructor.

Schema

{
  "<device_type>": {          // emulator | real_device | browserstack
    "default": "android",
    "android": { ...caps, "appiumServerUrl": "..." },
    "ios":     { ...caps, "appiumServerUrl": "..." }
  }
}

Profile strings

  • emulator → emulator[default]
  • emulator.ios → emulator.ios
  • browserstack → browserstack[default]

Example (abbreviated)

{
  "emulator": {
    "default": "android",
    "android": {
      "platformName": "Android",
      "appium:deviceName": "emulator-5554",
      "appium:app": "./path/to/your/app.apk",
      "appium:automationName": "UiAutomator2",
      "appiumServerUrl": "http://127.0.0.1:4723"
    },
    "ios": {
      "platformName": "iOS",
      "appium:deviceName": "iPhone 17",
      "appium:app": "./path/to/your/app.app",
      "appium:automationName": "XCUITest",
      "appiumServerUrl": "http://127.0.0.1:4723"
    }
  },
  "browserstack": {
    "default": "android",
    "android": {
      "platformName": "android",
      "appium:app": "bs://your-app-id",
      "bstack:options": {
        "userName": "$BROWSERSTACK_USERNAME",
        "accessKey": "$BROWSERSTACK_ACCESS_KEY"
      },
      "appiumServerUrl": "https://hub.browserstack.com"
    }
  }
}

Rules worth remembering:

  • Appium caps use the appium: prefix (W3C / Appium 2)
  • BrowserStack options live under bstack:options
  • appiumServerUrl is popped out by ConfigReader — it is not a W3C capability
  • Relative appium:app paths resolve against the config file directory
  • Strings like $BROWSERSTACK_USERNAME resolve from the process environment

Build the core/ (full source)

Implement in this order. Full listings follow so you can copy them into a new project.

1. ConfigReader

Loads JSON, resolves profile / env / app path, returns (capabilities, server_url).

import json
import os
import re

from glom import PathAccessError, glom

_ENV_VAR_PATTERN = re.compile(r"^\$([A-Za-z_][A-Za-z0-9_]*)$")
_CONFIG_ENV_VAR = "APPIUM_BDD_CONFIG"


class ConfigReader:
    def __init__(self, config_file_path=None):
        if config_file_path is None:
            config_file_path = os.environ.get(_CONFIG_ENV_VAR, "config.json")

        self.config_file_path = os.path.abspath(config_file_path)
        self._config_dir = os.path.dirname(self.config_file_path)

        with open(self.config_file_path, "r") as file:
            self.config_data = json.load(file)

    @staticmethod
    def _resolve_env_placeholders(value):
        if isinstance(value, str):
            match = _ENV_VAR_PATTERN.match(value)
            if match:
                env_var_name = match.group(1)
                env_value = os.environ.get(env_var_name)
                if env_value is None:
                    raise ValueError(
                        f"Environment variable '{env_var_name}' referenced in config.json "
                        "is not set."
                    )
                return env_value
            return value
        if isinstance(value, dict):
            return {
                key: ConfigReader._resolve_env_placeholders(val)
                for key, val in value.items()
            }
        if isinstance(value, list):
            return [ConfigReader._resolve_env_placeholders(item) for item in value]
        return value

    def _resolve_app_path(self, capabilities):
        app_path = capabilities.get("appium:app")
        if not app_path or not isinstance(app_path, str):
            return capabilities
        if app_path.startswith("bs://") or os.path.isabs(app_path):
            return capabilities
        capabilities["appium:app"] = os.path.normpath(
            os.path.join(self._config_dir, app_path)
        )
        return capabilities

    def _resolve_profile(self, profile):
        device_type, _, variant = profile.partition(".")

        try:
            group = glom(self.config_data, device_type)
            return glom(group, variant or group["default"])
        except (PathAccessError, KeyError) as exc:
            if device_type not in self.config_data:
                available = ", ".join(sorted(self.config_data))
                raise ValueError(
                    f"Device type '{device_type}' not found in config file. "
                    f"Available: {available}"
                ) from exc

            variants = ", ".join(
                sorted(k for k in self.config_data[device_type] if k != "default")
            )
            raise ValueError(
                f"Profile '{profile}' not found. "
                f"Available variants under '{device_type}': {variants}"
            ) from exc

    def get_capabilities(self, profile="emulator"):
        capabilities = self._resolve_env_placeholders(
            self._resolve_profile(profile).copy()
        )
        capabilities = self._resolve_app_path(capabilities)
        appium_server_url = capabilities.pop("appiumServerUrl", None)
        return capabilities, appium_server_url

2. Platform drivers

Appium Python Client v5 expects W3C Options — not the old desired_capabilities dict.

# core/driver/platforms/android_driver.py
from appium import webdriver
from appium.options.android import UiAutomator2Options


class AndroidDriver:
    def __init__(self, capabilities, appium_server_url):
        self.capabilities = capabilities
        self.appium_server_url = appium_server_url

    def create_driver(self):
        options = UiAutomator2Options()
        options.load_capabilities(self.capabilities)
        return webdriver.Remote(self.appium_server_url, options=options)
# core/driver/platforms/ios_driver.py
from appium import webdriver
from appium.options.ios import XCUITestOptions


class IOSDriver:
    def __init__(self, capabilities, appium_server_url):
        self.capabilities = capabilities
        self.appium_server_url = appium_server_url

    def create_driver(self):
        options = XCUITestOptions()
        options.load_capabilities(self.capabilities)
        return webdriver.Remote(self.appium_server_url, options=options)

3. DriverFactory + DriverManager

The factory branches on platformName only. Emulator vs BrowserStack is already encoded in caps + server URL.

from core.configuration.config_reader import ConfigReader
from core.driver.platforms.android_driver import AndroidDriver
from core.driver.platforms.ios_driver import IOSDriver


class DriverFactory:
    def __init__(self, profile):
        self.profile = profile
        self.config_reader = ConfigReader()

    def create_driver(self):
        capabilities, appium_server_url = self.config_reader.get_capabilities(
            self.profile
        )
        platform_name = capabilities.get("platformName").lower()

        if platform_name == "android":
            return AndroidDriver(capabilities, appium_server_url).create_driver()
        elif platform_name == "ios":
            return IOSDriver(capabilities, appium_server_url).create_driver()
        else:
            raise ValueError(f"Unsupported platform: {platform_name}")
from core.driver.driver_factory import DriverFactory


class DriverManager:
    _driver_instance = None

    @classmethod
    def initialize_driver(cls, profile="emulator"):
        if cls._driver_instance is None:
            factory = DriverFactory(profile)
            cls._driver_instance = factory.create_driver()
        return cls._driver_instance

    @classmethod
    def quit_driver(cls):
        if cls._driver_instance:
            cls._driver_instance.quit()
            cls._driver_instance = None

Hooks call initialize_driver / quit_driver. For parallel runs, swap the singleton for thread-local storage — the rest of the design stays the same.

4. DriverHelper

Session-level utilities only (alerts, context switches). No screen-specific logic.

from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import NoAlertPresentException

from core.driver.driver_manager import DriverManager


class DriverHelper:
    @staticmethod
    def is_alert_present():
        try:
            WebDriverWait(DriverManager._driver_instance, 3).until(
                EC.alert_is_present()
            )
            return True
        except NoAlertPresentException:
            return False

    @classmethod
    def accept_alert(cls):
        if DriverHelper.is_alert_present():
            alert = DriverManager._driver_instance.switch_to.alert
            alert.accept()

5. Element

One interaction surface. Supports a shared locator or per-platform locators. Gestures use W3C mobile:* commands.

from selenium.webdriver.remote.webelement import WebElement
from core.driver.driver_manager import DriverManager
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


class Element:
    def __init__(
        self,
        locator_type: By | None = None,
        locator_value: str | None = None,
        *,
        android: tuple[By, str] | None = None,
        ios: tuple[By, str] | None = None,
    ):
        if android is not None or ios is not None:
            self._platform_locators = {"android": android, "ios": ios}
            self.locator_type = None
            self.locator_value = None
        else:
            if locator_type is None or locator_value is None:
                raise ValueError(
                    "Provide either (locator_type, locator_value) "
                    "or android=/ios= platform locators."
                )
            self._platform_locators = None
            self.locator_type = locator_type
            self.locator_value = locator_value
        self._element: WebElement | None = None

    def _resolve_locator(self) -> tuple[By, str]:
        if self._platform_locators is None:
            return self.locator_type, self.locator_value

        platform = self.driver.capabilities.get("platformName", "").lower()
        locator = self._platform_locators.get(platform)
        if locator is None:
            raise ValueError(f"No locator defined for platform '{platform}'.")
        return locator

    def __str__(self):
        if self._platform_locators is not None:
            return f"Element(platform_locators={self._platform_locators})"
        return (
            f"Element(locator_type={self.locator_type}, "
            f"locator_value={self.locator_value})"
        )

    def __repr__(self):
        if self._platform_locators is not None:
            return f"Element(platform_locators={self._platform_locators!r})"
        return (
            f"Element(locator_type={self.locator_type!r}, "
            f"locator_value={self.locator_value!r})"
        )

    @property
    def driver(self):
        if not DriverManager._driver_instance:
            raise ValueError("Driver not initialized")
        return DriverManager._driver_instance

    def wait_for_element_present(self, timeout: int = 10) -> WebElement:
        return WebDriverWait(self.driver, timeout).until(
            EC.presence_of_element_located(self._resolve_locator())
        )

    def wait_for_element_visible(self, timeout: int = 10) -> WebElement:
        return WebDriverWait(self.driver, timeout).until(
            EC.visibility_of_element_located(self._resolve_locator())
        )

    def wait_for_element_clickable(self, timeout: int = 10) -> WebElement:
        return WebDriverWait(self.driver, timeout).until(
            EC.element_to_be_clickable(self._resolve_locator())
        )

    def find_element(self) -> WebElement:
        if not self._element:
            self._element = self.wait_for_element_present()
        return self._element

    def tap(self) -> None:
        element = self.wait_for_element_clickable()
        element.click()

    def type(self, text: str) -> None:
        element = self.wait_for_element_clickable()
        element.send_keys(text)

    def drag_and_drop(self, destination_element: WebElement) -> None:
        origin_el = self.find_element()
        origin_rect = origin_el.rect
        dest_rect = destination_element.rect

        start_x = origin_rect["x"] + origin_rect["width"] // 2
        start_y = origin_rect["y"] + origin_rect["height"] // 2
        end_x = dest_rect["x"] + dest_rect["width"] // 2
        end_y = dest_rect["y"] + dest_rect["height"] // 2

        platform = self.driver.capabilities.get("platformName", "").lower()
        if platform == "ios":
            self.driver.execute_script(
                "mobile: dragFromToForDuration",
                {
                    "duration": 1.0,
                    "fromX": start_x,
                    "fromY": start_y,
                    "toX": end_x,
                    "toY": end_y,
                },
            )
        else:
            self.driver.execute_script(
                "mobile: dragGesture",
                {
                    "elementId": origin_el.id,
                    "endX": end_x,
                    "endY": end_y,
                },
            )

    def swipe(self, end_x: int, end_y: int, duration: int = 0) -> None:
        start_rect = self.get_element_rect()
        start_x = start_rect["x"] + start_rect["width"] // 2
        start_y = start_rect["y"] + start_rect["height"] // 2

        direction = "up" if end_y < start_y else "down"
        self.driver.execute_script(
            "mobile: swipeGesture",
            {
                "left": start_x,
                "top": start_y,
                "width": 1,
                "height": 1,
                "direction": direction,
                "percent": 1.0,
            },
        )

    def get_text(self) -> str:
        element = self.wait_for_element_visible()
        return element.text

    def get_element_rect(self) -> dict:
        element = self.find_element()
        return {
            "x": element.rect["x"],
            "y": element.rect["y"],
            "width": element.rect["width"],
            "height": element.rect["height"],
        }

    def perform_swipe_up(self, duration=300):
        rect = self.driver.get_window_rect()
        self.driver.execute_script(
            "mobile: swipeGesture",
            {
                "left": rect["x"],
                "top": rect["y"],
                "width": rect["width"],
                "height": rect["height"],
                "direction": "up",
                "percent": 0.75,
            },
        )

    def scroll_to_element(self, max_scrolls=5, scroll_duration=300):
        for _ in range(max_scrolls):
            if self.is_displayed():
                return
            self.perform_swipe_up(duration=scroll_duration)

    def is_displayed(self) -> bool:
        try:
            return self.find_element().is_displayed()
        except Exception:
            return False

6. Locator

Declare Android + iOS tuples (with {param} templates) and resolve to Element.

import re
from typing import Any

from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.common.by import By

from core.element.element import Element

LocatorTuple = tuple[By, str]


class Locator:
    def __init__(self, *, android: LocatorTuple, ios: LocatorTuple):
        self.android = android
        self.ios = ios
        self._params = _template_params(android[1], ios[1])

    @classmethod
    def id(cls, name: str) -> Element:
        spec = (AppiumBy.ACCESSIBILITY_ID, name)
        return cls(android=spec, ios=spec).resolve()

    @classmethod
    def text(cls, text: str) -> Element:
        return cls(
            android=(AppiumBy.ANDROID_UIAUTOMATOR, f'new UiSelector().text("{text}")'),
            ios=(AppiumBy.IOS_PREDICATE, f'name == "{text}"'),
        ).resolve()

    def resolve(self, *args: Any, **kwargs: Any) -> Element:
        params = _bind_params(
            self._params, args, kwargs, name=getattr(self, "_name", "Locator")
        )
        return Element(
            android=_format(self.android, params),
            ios=_format(self.ios, params),
        )

    def __set_name__(self, owner, name):
        self._name = name

    def __get__(self, obj, objtype=None):
        return self.resolve


def _template_params(*values: str) -> list[str]:
    names: list[str] = []
    for value in values:
        for match in re.findall(r"\{(\w+)\}", value):
            if match not in names:
                names.append(match)
    return names


def _bind_params(
    param_names: list[str],
    args: tuple[Any, ...],
    kwargs: dict[str, Any],
    *,
    name: str,
) -> dict[str, Any]:
    if not param_names:
        return dict(kwargs)

    params = dict(kwargs)
    for index, arg in enumerate(args):
        if index >= len(param_names):
            raise TypeError(
                f"{name}() takes {len(param_names)} argument(s), "
                f"got {len(args)} positional."
            )
        params[param_names[index]] = arg

    missing = [key for key in param_names if key not in params]
    if missing:
        raise TypeError(f"{name}() missing argument(s): {', '.join(missing)}")

    return params


def _format(spec: LocatorTuple, params: dict[str, Any]) -> LocatorTuple:
    by, value = spec
    return by, value.format(**params) if params else value

Class attributes become callables via __get__:

class DragPage:
    _box_drop = Locator(
        android=(AppiumBy.ACCESSIBILITY_ID, "drop-{order}"),
        ios=(AppiumBy.IOS_PREDICATE, 'name == "drop-{order}"'),
    )

# self._box_drop("l2")  → Element

7. Strategies + annotations

Annotation keywords map to AppiumBy. Add a strategy = one dict entry.

from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.common.by import By

STRATEGIES: dict[str, By] = {
    "accessibility": AppiumBy.ACCESSIBILITY_ID,
    "accessibility_id": AppiumBy.ACCESSIBILITY_ID,
    "id": AppiumBy.ACCESSIBILITY_ID,
    "xpath": AppiumBy.XPATH,
    "uiautomator": AppiumBy.ANDROID_UIAUTOMATOR,
    "android_uiautomator": AppiumBy.ANDROID_UIAUTOMATOR,
    "predicate": AppiumBy.IOS_PREDICATE,
    "ios_predicate": AppiumBy.IOS_PREDICATE,
    "class_chain": AppiumBy.IOS_CLASS_CHAIN,
    "ios_class_chain": AppiumBy.IOS_CLASS_CHAIN,
}


def strategy_tuple(**kwargs: str) -> tuple[By, str]:
    if len(kwargs) != 1:
        raise ValueError(f"Expected exactly one strategy keyword, got: {kwargs}")

    name, value = next(iter(kwargs.items()))
    by = STRATEGIES.get(name)
    if by is None:
        supported = ", ".join(sorted(STRATEGIES))
        raise ValueError(f"Unknown strategy '{name}'. Supported: {supported}")

    return by, value
import inspect
from collections.abc import Callable
from functools import wraps
from typing import Any

from core.element.locator import Locator
from core.element.strategies import strategy_tuple

_FIND_BY = "__find_by__"


def AndroidFindBy(**kwargs: str) -> Callable:
    return _platform_find_by("android", kwargs)


def iOSFindBy(**kwargs: str) -> Callable:
    return _platform_find_by("ios", kwargs)


def _platform_find_by(platform: str, kwargs: dict[str, str]) -> Callable:
    if not kwargs:
        raise ValueError(f"@{platform}FindBy requires a locator strategy keyword.")

    spec = strategy_tuple(**kwargs)

    def decorator(func: Callable) -> Callable:
        if getattr(func, "_is_find_by", False):
            target = func
        else:
            target = _wrap(func)

        pairs: dict[str, tuple] = getattr(target, _FIND_BY, {})
        pairs[platform] = spec
        setattr(target, _FIND_BY, pairs)
        return target

    return decorator


def _wrap(func: Callable) -> Callable:
    @wraps(func)
    def wrapper(self, *args, **kwargs):
        pairs: dict[str, tuple] = getattr(wrapper, _FIND_BY)
        if "android" not in pairs or "ios" not in pairs:
            raise ValueError(
                f"{func.__name__} needs both @AndroidFindBy and @iOSFindBy."
            )

        params = _method_params(func, self, args, kwargs)
        return Locator(android=pairs["android"], ios=pairs["ios"]).resolve(**params)

    wrapper._is_find_by = True
    return wrapper


def _method_params(
    func: Callable, self: Any, args: tuple, kwargs: dict
) -> dict[str, Any]:
    bound = inspect.signature(func).bind(self, *args, **kwargs)
    bound.apply_defaults()
    return {key: value for key, value in bound.arguments.items() if key != "self"}

Both platforms are required. Method params (e.g. order) feed {order} in locator strings.

8. AllureReport + JsonUtil

import os
import allure
from allure_commons.types import AttachmentType
from behave import fixture


class AllureReport:
    def __init__(self):
        self.results_dir = "allure-results"
        if not os.path.exists(self.results_dir):
            os.makedirs(self.results_dir)

    @fixture
    def setup_allure_report(self, context):
        context.results_dir = self.results_dir
        context.allure = allure
        yield

    def log_step(self, step_name, status, exception=None):
        with allure.step(step_name):
            if status == "failed":
                allure.attach(
                    str(exception),
                    name="Exception Details",
                    attachment_type=AttachmentType.TEXT,
                )
            allure.step(f"Step Status: {status}")

    def attach_screenshot(self, context, name="Screenshot"):
        if hasattr(context, "driver"):
            screenshot = context.driver.get_screenshot_as_png()
            allure.attach(
                screenshot, name=name, attachment_type=AttachmentType.PNG
            )

    def attach_exception(self, step):
        if step.status == "failed":
            allure.attach(
                str(step.exception),
                name="Exception",
                attachment_type=allure.attachment_type.TEXT,
            )

Wire Allure from Behave hooks — not from page objects.

import json
from typing import Type, TypeVar, Dict

T = TypeVar("T")


class JsonUtil:
    @staticmethod
    def parse_json_to_dto(json_path: str, DtoClass: Type[T]) -> Dict[str, T]:
        with open(json_path, "r") as file:
            data = json.load(file)
        return {key: DtoClass(**value) for key, value in data.items()}

Using core from a suite

Your project folder is not the framework. It sits on top:

src/
├── config.json
├── model/
├── pages/
├── resources/
└── tests/
    ├── environment.py
    ├── features/
    └── steps/

Hooks put the repo on sys.path, point APPIUM_BDD_CONFIG at your JSON, and own the session:

import os
import sys
from pathlib import Path

from behave.runner import Context
from behave.fixture import use_fixture

_SRC_ROOT = Path(__file__).resolve().parents[1]
_REPO_ROOT = _SRC_ROOT.parent
for _path in (_REPO_ROOT, _SRC_ROOT):
    _path_str = str(_path)
    if _path_str not in sys.path:
        sys.path.insert(0, _path_str)

os.environ.setdefault("APPIUM_BDD_CONFIG", str(_SRC_ROOT / "config.json"))

from core.driver.driver_manager import DriverManager
from core.report.allurereport import AllureReport

allure_report = AllureReport()


def before_all(context: Context):
    use_fixture(allure_report.setup_allure_report, context)


def before_feature(context, feature):
    profile = context.config.userdata.get("profile", "emulator")
    context.driver = DriverManager.initialize_driver(profile=profile)


def after_feature(context, feature):
    DriverManager.quit_driver()


def after_step(context, step):
    allure_report.log_step(
        step_name=step.name,
        status=step.status,
        exception=step.exception if step.status == "failed" else None,
    )
    if step.status == "failed":
        allure_report.attach_screenshot(context)

Page objects stay thin — locators + actions only:

from appium.webdriver.common.appiumby import AppiumBy
from core.element.locator import Locator
from core.element.annotations import AndroidFindBy, iOSFindBy
from core.element.element import Element


class LoginPage:
    _msg_invalid = Locator(
        android=(AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("{label}")'),
        ios=(AppiumBy.IOS_PREDICATE, 'name == "{label}"'),
    )

    def __init__(self):
        self._btn_login = Locator.id("button-LOGIN")
        self._txt_login_email = Locator.id("input-email")

    def enter_email(self, email: str) -> None:
        self._txt_login_email.type(email)

    def tap_login(self) -> None:
        self._btn_login.tap()


class NavigationBar:
    @AndroidFindBy(xpath="//android.widget.TextView[@text='{label}']")
    @iOSFindBy(accessibility="{label}")
    def _btn_view(self, label: str) -> Element: ...

    def click_view(self, label: str) -> None:
        self._btn_view(label).tap()

Run

uv run behave -D profile=emulator
uv run behave -D profile=emulator.ios
uv run behave -D profile=browserstack
uv run behave -i login -D profile=emulator

Suggested build order

  1. Pin Python + Appium client deps (pyproject.tomluv.lock.python-version)
  2. config.json schema + ConfigReader — prove profile resolution without a device
  3. Platform drivers + DriverFactory + DriverManager — launch from a 10-line script
  4. Element — waits + tap / type
  5. Locator — collapse duplicated Android/iOS page classes
  6. strategies + @AndroidFindBy / @iOSFindBy — optional DX sugar
  7. Gestures, DriverHelper, Allure — as your apps need them
  8. Your suite — pages, models, features, hooks pointing APPIUM_BDD_CONFIG at your JSON

Closing

Rebuild the boundaries first. Then your suite becomes a thin illustration: pages call Element, hooks pick a profile, and config.json decides whether that profile is an Android emulator, an iPhone simulator, or BrowserStack.

Picture of thinhnguyenphamphu

thinhnguyenphamphu

Suggested Article

Scroll to Top