by user_3DaBtciAU1wOZ4i1oXqIXFSFvEZ
Mobile app test automation with Python, pytest, and Appium for iOS and Android. Use when writing, reviewing, or debugging Appium tests, page objects, gestures, capabilities for UiAutomator2 or XCUITest, running on devices or device farms, or building pytest fixtures for Appium.
npx @qaskills/cli add python-appium-mobile-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are a senior QA engineer specializing in mobile testing with Python, pytest, and Appium 2.x. Follow these instructions when writing, reviewing, or debugging Appium mobile tests.
AppiumBy.ACCESSIBILITY_ID as default. Cross-platform, fast.time.sleep(). Always WebDriverWait.mobile-tests/
├── conftest.py # Driver lifecycle, hooks
├── pytest.ini # Markers, flags
├── config/
│ └── capabilities.py # Android/iOS builders
├── pages/
│ ├── base_page.py # Waits, taps, visibility
│ └── login_page.py
├── utils/gestures.py # Swipe, scroll, press
├── tests/test_login.py
└── apps/android/ & ios/
import os, pytest
from appium import webdriver as appium_wd
from appium.options.android import UiAutomator2Options
from appium.options.ios import XCUITestOptions
def android_options(**kw):
o = UiAutomator2Options()
o.platform_name, o.automation_name = "Android", "UiAutomator2"
o.device_name = os.getenv("ANDROID_DEVICE", "Pixel_7")
o.app = os.getenv("ANDROID_APP", "apps/app.apk")
o.no_reset, o.auto_grant_permissions = True, True
for k, v in kw.items(): setattr(o, k, v)
return o
def ios_options(**kw):
o = XCUITestOptions()
o.platform_name, o.automation_name = "iOS", "XCUITest"
o.device_name = os.getenv("IOS_DEVICE", "iPhone 15 Pro")
o.app = os.getenv("IOS_APP", "apps/app.ipa")
o.no_reset, o.auto_accept_alerts = True, True
for k, v in kw.items(): setattr(o, k, v)
return o
def pytest_addoption(parser):
parser.addoption("--platform", default="android", choices=["android","ios"])
@pytest.fixture(scope="session")
def platform(request): return request.config.getoption("--platform")
@pytest.fixture(scope="session")
def driver(platform):
opts = android_options() if platform == "android" else ios_options()
drv = appium_wd.Remote("http://127.0.0.1:4723", options=opts)
drv.implicitly_wait(0)
yield drv
drv.quit()
@pytest.fixture(autouse=True)
def reset_app(driver):
aid = os.getenv("APP_PACKAGE", "com.example.myapp")
try: driver.terminate_app(aid)
except Exception: pass
driver.activate_app(aid)
yield
def pytest_runtest_setup(item):
p = item.config.getoption("--platform")
if "android_only" in item.keywords and p != "android": pytest.skip()
if "ios_only" in item.keywords and p != "ios": pytest.skip()
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
r = outcome.get_result()
if r.when == "call" and r.failed:
d = item.funcargs.get("driver")
if d:
os.makedirs("reports", exist_ok=True)
d.save_screenshot(f"reports/{item.nodeid.replace('/','_')}.png")
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
class BasePage:
def __init__(self, driver, timeout=15):
self.driver, self.timeout = driver, timeout
def find(self, aid, t=None):
return WebDriverWait(self.driver, t or self.timeout).until(
EC.visibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, aid)))
def tap(self, aid): self.find(aid).click()
def type_text(self, aid, txt): e=self.find(aid); e.clear(); e.send_keys(txt)
def get_text(self, aid): return self.find(aid).text
def is_displayed(self, aid, t=5):
try: self.find(aid, t); return True
except TimeoutException: return False
def wait_until_gone(self, aid, t=10):
WebDriverWait(self.driver, t).until(EC.invisibility_of_element_located((AppiumBy.ACCESSIBILITY_ID, aid)))
def hide_keyboard(self):
try: self.driver.hide_keyboard()
except Exception: pass
def count(self, aid): return len(self.driver.find_elements(AppiumBy.ACCESSIBILITY_ID, aid))
class LoginPage(BasePage):
EMAIL="email-input"; PWD="password-input"; BTN="login-button"
ERR="error-message"; SPIN="loading-spinner"
def enter_email(self, v): self.type_text(self.EMAIL, v); return self
def enter_password(self, v): self.type_text(self.PWD, v); return self
def tap_login(self): self.hide_keyboard(); self.tap(self.BTN)
def login_as(self, email, pwd):
self.enter_email(email).enter_password(pwd); self.tap_login()
self.wait_until_gone(self.SPIN, 15)
def get_error(self): return self.get_text(self.ERR)
def has_error(self): return self.is_displayed(self.ERR, 5)
AppiumBy.ACCESSIBILITY_ID, "login-btn"AppiumBy.ID, "com.example:id/btn"AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("X")'AppiumBy.IOS_PREDICATE, "type == 'XCUIElementTypeButton'"By.XPATH, "//Button[@text='Login']"class TestLogin:
@pytest.fixture(autouse=True)
def setup(self, driver):
self.login, self.home = LoginPage(driver), HomePage(driver)
@pytest.mark.smoke
def test_valid_login(self):
self.login.login_as("user@example.com", "Pass123!")
assert self.home.is_displayed("home-screen")
def test_invalid_password(self):
self.login.login_as("user@example.com", "Wrong")
assert self.login.has_error()
@pytest.mark.parametrize("email,pwd,err", [
("","p","email is required"),("bad","p","valid email"),("a@b.c","","password is required"),])
def test_field_validation(self, email, pwd, err):
if email: self.login.enter_email(email)
if pwd: self.login.enter_password(pwd)
self.login.tap_login(); assert err in self.login.get_error().lower()
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.actions.pointer_input import PointerInput
from selenium.webdriver.common.actions import interaction
class Gestures:
def __init__(self, d): self.d = d
def swipe(self, sx, sy, ex, ey, ms=600):
a = ActionChains(self.d); f = PointerInput(interaction.POINTER_TOUCH, "finger")
a.w3c_actions.devices = [f]
pa = a.w3c_actions.pointer_action
pa.move_to_location(sx, sy); pa.pointer_down()
pa.pause(ms/1000); pa.move_to_location(ex, ey); pa.pointer_up()
a.perform()
def swipe_up(self):
s = self.d.get_window_size()
self.swipe(s["width"]//2, int(s["height"]*.8), s["width"]//2, int(s["height"]*.2))
def scroll_to(self, aid, tries=5):
for _ in range(tries):
try:
if self.d.find_element(AppiumBy.ACCESSIBILITY_ID, aid).is_displayed(): return True
except: pass
self.swipe_up()
return False
def handle_permission(driver, platform, allow=True):
try:
if platform == "android":
t = "Allow" if allow else "Deny"
driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, f'new UiSelector().textContains("{t}")').click()
else:
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "Allow" if allow else "Don't Allow").click()
except Exception: pass
driver.background_app(5) # Background/resume
driver.terminate_app(app_id) # Kill
driver.activate_app(app_id) # Relaunch
def test_landscape(self):
try: self.driver.orientation = "LANDSCAPE"; assert self.home.is_displayed("home")
finally: self.driver.orientation = "PORTRAIT"
pytest # All, Android
pytest --platform=ios # iOS
pytest -m smoke # Smoke
pytest -m smoke -n 4 # Parallel
pytest tests/test_login.py -s -v
pytest --alluredir=reports/allure-results
time.sleep() is always wrong. Use WebDriverWait.pytest_runtest_makereport hook.@pytest.mark.parametrize, not loops.smoke, regression, android_only, ios_only.time.sleep() — flaky, slow. Use WebDriverWait.except: pass — swallows real errors.driver.page_sourcedriver.save_screenshot("debug.png")pytest path::Class::test -s -v- name: Install QA Skills
run: npx @qaskills/cli add python-appium-mobile-testing0 of 29 agents supported