A consistent project structure makes it easy to navigate a test suite when tests fail at 2am. This lesson describes the directory layout, file responsibilities, and conftest.py patterns used throughout this course.
Recommended directory layout
appium-python-suite/
├── .env # device names, server URL (gitignored)
├── .gitignore
├── requirements.txt
├── pytest.ini
├── conftest.py # root conftest — driver fixture, hooks
├── pages/
│ ├── __init__.py
│ ├── base_page.py # shared wait helpers
│ ├── login_page.py
│ ├── home_page.py
│ ├── product_detail_page.py
│ └── cart_page.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # test-level fixtures (if needed)
│ ├── test_login.py
│ ├── test_products.py
│ └── test_checkout.py
├── utils/
│ ├── __init__.py
│ ├── gesture_utils.py
│ └── wait_utils.py
└── apps/
├── app-debug.apk
└── MyApp.app
conftest.py — the session entrypoint
# conftest.py
import os
import pytest
from dotenv import load_dotenv
from appium import webdriver
from appium.options import UiAutomator2Options, XCUITestOptions
load_dotenv()
APPIUM_URL = os.getenv("APPIUM_SERVER", "http://127.0.0.1:4723")
PLATFORM = os.getenv("PLATFORM", "Android")
@pytest.fixture(scope="function")
def driver(request):
platform = request.param if hasattr(request, "param") else PLATFORM
if platform == "Android":
options = UiAutomator2Options()
options.device_name = os.getenv("ANDROID_DEVICE", "emulator-5554")
options.app = os.path.abspath("apps/app-debug.apk")
options.auto_grant_permissions = True
options.no_reset = True
else:
options = XCUITestOptions()
options.device_name = os.getenv("IOS_DEVICE", "iPhone 15")
options.app = os.path.abspath("apps/MyApp.app")
options.no_reset = True
d = webdriver.Remote(APPIUM_URL, options=options)
yield d
d.quit()
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
item.stash[pytest.StashKey[object]()] = rep
setattr(item, "rep_" + rep.when, rep)The pytest_runtest_makereport hook stores the test result on the item object. Fixtures can then check request.node.rep_call.failed to see if the test failed.
base_page.py — shared driver helpers
# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, NoSuchElementException
class BasePage:
DEFAULT_TIMEOUT = 15
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, self.DEFAULT_TIMEOUT)
def find(self, locator):
return self.driver.find_element(*locator)
def find_all(self, locator):
return self.driver.find_elements(*locator)
def wait_for_visible(self, locator):
return self.wait.until(EC.visibility_of_element_located(locator))
def wait_for_clickable(self, locator):
return self.wait.until(EC.element_to_be_clickable(locator))
def wait_for_invisible(self, locator):
self.wait.until(EC.invisibility_of_element_located(locator))
def is_present(self, locator, timeout=3):
try:
WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located(locator)
)
return True
except TimeoutException:
return FalseAll page objects inherit from BasePage and get these methods for free.
pytest.ini configuration
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = --tb=short -v
markers =
smoke: core functionality, runs on every PR
regression: full suite, runs nightly
slow: tests over 60 secondsRunning the suite
# All tests
pytest
# Smoke only
pytest -m smoke
# Specific platform (via environment variable)
PLATFORM=iOS pytest
# Parallel (covered in Chapter 5)
pytest -n 2utils/gesture_utils.py skeleton
# utils/gesture_utils.py
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.actions.action_builder import ActionBuilder
from selenium.webdriver.common.actions.pointer_input import PointerInput
from selenium.webdriver.common.actions import interaction
import time
class GestureUtils:
def __init__(self, driver):
self.driver = driver
def tap(self, element):
location = element.location
size = element.size
x = location["x"] + size["width"] // 2
y = location["y"] + size["height"] // 2
self._perform_tap(x, y)
def swipe_up(self):
size = self.driver.get_window_size()
self._perform_swipe(
size["width"] // 2,
int(size["height"] * 0.7),
size["width"] // 2,
int(size["height"] * 0.3),
duration=0.5
)
def _perform_tap(self, x, y):
finger = PointerInput(interaction.POINTER_TOUCH, "finger")
actions = ActionBuilder(self.driver, mouse=finger)
actions.pointer_action.move_to_location(x, y).pointer_down().pointer_up()
actions.perform()
def _perform_swipe(self, start_x, start_y, end_x, end_y, duration=0.5):
finger = PointerInput(interaction.POINTER_TOUCH, "finger")
actions = ActionBuilder(self.driver, mouse=finger)
actions.pointer_action\
.move_to_location(start_x, start_y)\
.pointer_down()\
.pause(duration)\
.move_to_location(end_x, end_y)\
.pointer_up()
actions.perform()