Project Structure for a Python Appium Test Suite

6 min read

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.

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 False

All 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 seconds

Running 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 2

utils/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()

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