appium-python-expert — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited appium-python-expert (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Specialist in mobile E2E test automation using Appium 2.x + Python + pytest. Covers Android (UIAutomator2) and iOS (XCUITest), Page Object Model, Allure reports, gestures táctiles, Appium Inspector y CI/CD integration.
| Layer | Tool |
|---|---|
| Appium | 2.x (drivers plugin-based) |
| Client | Appium-Python-Client >= 3.x |
| Test runner | pytest + pytest-html |
| Reporting | allure-pytest + Allure CLI |
| Android driver | uiautomator2 |
| iOS driver | xcuitest |
| CI/CD | GitHub Actions, Jenkins |
| Utilities | python-dotenv |
| Inspector | Appium Inspector (desktop app) |
project/
├── tests/
│ ├── android/
│ │ └── test_login_android.py
│ ├── ios/
│ │ └── test_login_ios.py
│ └── conftest.py
├── pages/
│ ├── base_page.py ← gestures incluidos aquí
│ ├── login_page.py
│ └── home_page.py
├── config/
│ ├── capabilities_android.py
│ └── capabilities_ios.py
├── utils/
│ └── helpers.py
├── allure-results/ ← generado por pytest
├── allure-report/ ← generado por allure CLI
├── .env
├── pytest.ini
├── requirements.txt
└── README.mdnpm install -g appium@latest
appium driver install uiautomator2
appium driver install xcuitest
appium driver list --installed # verifyrequirements.txt)Appium-Python-Client>=3.1.0
pytest>=8.0.0
pytest-html>=4.0.0
python-dotenv>=1.0.0
allure-pytest>=2.13.0Allure también requiere el CLI separado: ver sección Allure Reports más abajo.
pip install -r requirements.txt.env)ANDROID_APP_PATH=/abs/path/to/app.apk
IOS_APP_PATH=/abs/path/to/app.ipa
APPIUM_HOST=127.0.0.1
APPIUM_PORT=4723
ANDROID_DEVICE_NAME=emulator-5554
IOS_DEVICE_NAME=iPhone 15
IOS_PLATFORM_VERSION=17.0config/capabilities_android.py)import os
from dotenv import load_dotenv
load_dotenv()
ANDROID_CAPS = {
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": os.getenv("ANDROID_DEVICE_NAME", "emulator-5554"),
"appium:app": os.getenv("ANDROID_APP_PATH"),
"appium:noReset": False,
"appium:newCommandTimeout": 120,
"appium:autoGrantPermissions": True,
}config/capabilities_ios.py)import os
from dotenv import load_dotenv
load_dotenv()
IOS_CAPS = {
"platformName": "iOS",
"appium:automationName": "XCUITest",
"appium:deviceName": os.getenv("IOS_DEVICE_NAME", "iPhone 15"),
"appium:platformVersion": os.getenv("IOS_PLATFORM_VERSION", "17.0"),
"appium:app": os.getenv("IOS_APP_PATH"),
"appium:noReset": False,
"appium:newCommandTimeout": 120,
"appium:autoAcceptAlerts": True,
}import pytest
from appium import webdriver
from appium.options import AppiumOptions
from config.capabilities_android import ANDROID_CAPS
from config.capabilities_ios import IOS_CAPS
import os
APPIUM_URL = f"http://{os.getenv('APPIUM_HOST','127.0.0.1')}:{os.getenv('APPIUM_PORT','4723')}"
def get_driver(platform: str):
caps = ANDROID_CAPS if platform == "android" else IOS_CAPS
options = AppiumOptions().load_capabilities(caps)
return webdriver.Remote(APPIUM_URL, options=options)
@pytest.fixture(scope="function")
def android_driver():
driver = get_driver("android")
yield driver
driver.quit()
@pytest.fixture(scope="function")
def ios_driver():
driver = get_driver("ios")
yield driver
driver.quit()pages/base_page.pyfrom appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class BasePage:
DEFAULT_TIMEOUT = 15
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, self.DEFAULT_TIMEOUT)
def find(self, by, value):
return self.wait.until(EC.presence_of_element_located((by, value)))
def click(self, by, value):
self.find(by, value).click()
def type_text(self, by, value, text):
el = self.find(by, value)
el.clear()
el.send_keys(text)
def get_text(self, by, value) -> str:
return self.find(by, value).text
def is_visible(self, by, value) -> bool:
try:
self.wait.until(EC.visibility_of_element_located((by, value)))
return True
except Exception:
return False
def swipe_up(self):
size = self.driver.get_window_size()
self.driver.swipe(
size["width"] // 2, int(size["height"] * 0.8),
size["width"] // 2, int(size["height"] * 0.2),
duration=500
)pages/login_page.pyfrom appium.webdriver.common.appiumby import AppiumBy
from pages.base_page import BasePage
class LoginPage(BasePage):
# Android locators (UIAutomator2)
USERNAME_FIELD = (AppiumBy.ACCESSIBILITY_ID, "username_input")
PASSWORD_FIELD = (AppiumBy.ACCESSIBILITY_ID, "password_input")
LOGIN_BUTTON = (AppiumBy.ACCESSIBILITY_ID, "login_button")
ERROR_MESSAGE = (AppiumBy.ID, "com.example.app:id/error_text")
def login(self, username: str, password: str):
self.type_text(*self.USERNAME_FIELD, username)
self.type_text(*self.PASSWORD_FIELD, password)
self.click(*self.LOGIN_BUTTON)
def get_error(self) -> str:
return self.get_text(*self.ERROR_MESSAGE)
def is_login_visible(self) -> bool:
return self.is_visible(*self.LOGIN_BUTTON)tests/android/test_login_android.pyimport pytest
from pages.login_page import LoginPage
from pages.home_page import HomePage
class TestLoginAndroid:
def test_valid_login(self, android_driver):
login = LoginPage(android_driver)
home = HomePage(android_driver)
assert login.is_login_visible(), "Login screen not displayed"
login.login("[email protected]", "ValidPass123")
assert home.is_home_visible(), "Home screen not displayed after login"
def test_invalid_login(self, android_driver):
login = LoginPage(android_driver)
login.login("[email protected]", "badpass")
error = login.get_error()
assert "Invalid" in error, f"Expected error message, got: {error}"
@pytest.mark.parametrize("user,pwd", [
("", "pass123"),
("[email protected]", ""),
("", ""),
])
def test_empty_fields(self, android_driver, user, pwd):
login = LoginPage(android_driver)
login.login(user, pwd)
assert login.is_login_visible(), "Should remain on login screen"[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--html=reports/report.html
--self-contained-html
--alluredir=allure-results
markers =
android: marks tests as android-only
ios: marks tests as ios-only
smoke: smoke test suite
regression: full regression suite# macOS
brew install allure
# Linux (via npm)
npm install -g allure-commandline
# Windows (via scoop)
scoop install allureimport allure
import pytest
from pages.login_page import LoginPage
@allure.epic("Autenticación")
@allure.feature("Login")
class TestLoginAndroid:
@allure.story("Login exitoso")
@allure.severity(allure.severity_level.CRITICAL)
@allure.title("Usuario válido puede ingresar a la app")
def test_valid_login(self, android_driver):
with allure.step("Abrir pantalla de login"):
login = LoginPage(android_driver)
assert login.is_login_visible()
with allure.step("Ingresar credenciales válidas"):
login.login("[email protected]", "ValidPass123")
with allure.step("Verificar redirección a home"):
from pages.home_page import HomePage
assert HomePage(android_driver).is_home_visible()
@allure.story("Login fallido")
@allure.severity(allure.severity_level.NORMAL)
def test_invalid_login(self, android_driver):
login = LoginPage(android_driver)
with allure.step("Intentar login con credenciales inválidas"):
login.login("[email protected]", "badpass")
with allure.step("Verificar mensaje de error"):
assert "Invalid" in login.get_error()conftest.py)import allure
import pytest
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
if rep.when == "call" and rep.failed:
driver = item.funcargs.get("android_driver") or item.funcargs.get("ios_driver")
if driver:
allure.attach(
driver.get_screenshot_as_png(),
name="screenshot_on_failure",
attachment_type=allure.attachment_type.PNG
)# Correr tests
pytest tests/android/ -m smoke
# Generar reporte HTML
allure generate allure-results --clean -o allure-report
# Abrir en browser
allure open allure-report
# Modo serve (live)
allure serve allure-resultsPara detalles completos de configuración y uso → references/appium_inspector.md
http://127.0.0.1:4723{
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "emulator-5554",
"appium:app": "/abs/path/to/app.apk",
"appium:noReset": true
}| Estrategia | Usar cuando |
|---|---|
| Accessibility ID | El elemento tiene content-desc (Android) o accessibilityIdentifier (iOS) |
| ID | resource-id en Android |
| XPath | Último recurso; copiar desde Inspector y simplificar |
-android uiautomator | Búsquedas complejas en Android (texto, clase, índice) |
-ios predicate string | iOS: label, name, value, type |
-ios class chain | iOS: jerarquía de elementos |
Para referencia completa con todos los gestures → references/gestures.md
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
def _touch_action(driver):
"""Helper: devuelve un PointerInput táctil."""
return PointerInput(interaction.POINTER_TOUCH, "touch")pages/base_page.py (extendido)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
class BasePage:
DEFAULT_TIMEOUT = 15
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, self.DEFAULT_TIMEOUT)
# ── Métodos básicos ──────────────────────────────────────────────
def find(self, by, value):
return self.wait.until(EC.presence_of_element_located((by, value)))
def click(self, by, value):
self.find(by, value).click()
def type_text(self, by, value, text):
el = self.find(by, value)
el.clear()
el.send_keys(text)
def get_text(self, by, value) -> str:
return self.find(by, value).text
def is_visible(self, by, value) -> bool:
try:
self.wait.until(EC.visibility_of_element_located((by, value)))
return True
except Exception:
return False
# ── Gestures W3C ────────────────────────────────────────────────
def _get_size(self):
return self.driver.get_window_size()
def swipe(self, start_x: int, start_y: int, end_x: int, end_y: int, duration_ms: int = 500):
"""Swipe genérico de un punto a otro."""
touch = PointerInput(interaction.POINTER_TOUCH, "touch")
actions = ActionChains(self.driver)
actions.w3c_actions = ActionBuilder(self.driver, mouse=touch)
(actions.w3c_actions
.pointer_action
.move_to_location(start_x, start_y)
.pointer_down()
.pause(duration_ms / 1000)
.move_to_location(end_x, end_y)
.pointer_up())
actions.perform()
def swipe_up(self, percent: float = 0.6):
"""Scroll hacia arriba (descubrir contenido debajo)."""
size = self._get_size()
cx = size["width"] // 2
self.swipe(cx, int(size["height"] * 0.8), cx, int(size["height"] * (0.8 - percent)))
def swipe_down(self, percent: float = 0.6):
"""Scroll hacia abajo (pull-to-refresh o volver arriba)."""
size = self._get_size()
cx = size["width"] // 2
self.swipe(cx, int(size["height"] * 0.2), cx, int(size["height"] * (0.2 + percent)))
def swipe_left(self, percent: float = 0.6):
"""Swipe a la izquierda (carousels, tabs, delete)."""
size = self._get_size()
cy = size["height"] // 2
self.swipe(int(size["width"] * 0.8), cy, int(size["width"] * (0.8 - percent)), cy)
def swipe_right(self, percent: float = 0.6):
"""Swipe a la derecha (volver, abrir drawer)."""
size = self._get_size()
cy = size["height"] // 2
self.swipe(int(size["width"] * 0.2), cy, int(size["width"] * (0.2 + percent)), cy)
def swipe_on_element(self, element, direction: str = "up"):
"""Swipe dentro de un elemento (listas, sliders)."""
loc = element.location
sz = element.size
cx = loc["x"] + sz["width"] // 2
if direction == "up":
self.swipe(cx, loc["y"] + int(sz["height"] * 0.8),
cx, loc["y"] + int(sz["height"] * 0.2))
elif direction == "down":
self.swipe(cx, loc["y"] + int(sz["height"] * 0.2),
cx, loc["y"] + int(sz["height"] * 0.8))
def long_press(self, by, value, duration_ms: int = 1500):
"""Long press sobre un elemento."""
el = self.find(by, value)
touch = PointerInput(interaction.POINTER_TOUCH, "touch")
actions = ActionChains(self.driver)
actions.w3c_actions = ActionBuilder(self.driver, mouse=touch)
(actions.w3c_actions
.pointer_action
.move_to_location(el.location["x"] + el.size["width"] // 2,
el.location["y"] + el.size["height"] // 2)
.pointer_down()
.pause(duration_ms / 1000)
.pointer_up())
actions.perform()
def double_tap(self, by, value):
"""Doble tap sobre un elemento."""
el = self.find(by, value)
cx = el.location["x"] + el.size["width"] // 2
cy = el.location["y"] + el.size["height"] // 2
touch = PointerInput(interaction.POINTER_TOUCH, "touch")
actions = ActionChains(self.driver)
actions.w3c_actions = ActionBuilder(self.driver, mouse=touch)
(actions.w3c_actions
.pointer_action
.move_to_location(cx, cy)
.pointer_down().pointer_up()
.pause(0.1)
.pointer_down().pointer_up())
actions.perform()
def pinch_zoom(self, scale: float = 2.0):
"""
Pinch (scale < 1.0) o zoom (scale > 1.0) en el centro de la pantalla.
Requiere dos dedos simultáneos.
"""
size = self._get_size()
cx, cy = size["width"] // 2, size["height"] // 2
offset = int(min(cx, cy) * 0.3)
f1 = PointerInput(interaction.POINTER_TOUCH, "finger1")
f2 = PointerInput(interaction.POINTER_TOUCH, "finger2")
if scale >= 1.0: # zoom in
f1_start, f1_end = cx - offset, cx - int(offset * scale)
f2_start, f2_end = cx + offset, cx + int(offset * scale)
else: # pinch out
f1_start, f1_end = cx - int(offset * scale), cx - offset
f2_start, f2_end = cx + int(offset * scale), cx + offset
actions = ActionChains(self.driver)
actions.w3c_actions = ActionBuilder(self.driver, mouse=f1, wheel=f2)
(actions.w3c_actions.pointer_action
.move_to_location(f1_start, cy).pointer_down()
.move_to_location(f1_end, cy).pointer_up())
actions.perform()
def scroll_to_element(self, by, value, max_swipes: int = 5) -> bool:
"""Hace swipe_up hasta encontrar el elemento. Retorna True si lo encontró."""
for _ in range(max_swipes):
try:
el = self.driver.find_element(by, value)
if el.is_displayed():
return True
except Exception:
pass
self.swipe_up()
return False
def drag_and_drop(self, source_by, source_val, target_by, target_val):
"""Arrastra un elemento sobre otro."""
src = self.find(source_by, source_val)
tgt = self.find(target_by, target_val)
sx = src.location["x"] + src.size["width"] // 2
sy = src.location["y"] + src.size["height"] // 2
tx = tgt.location["x"] + tgt.size["width"] // 2
ty = tgt.location["y"] + tgt.size["height"] // 2
self.swipe(sx, sy, tx, ty, duration_ms=1000)@allure.story("Carrusel de productos")
def test_carousel_swipe(self, android_driver):
page = ProductPage(android_driver)
with allure.step("Hacer swipe left en el carrusel"):
page.swipe_left()
with allure.step("Verificar segundo item visible"):
assert page.is_second_item_visible()
@allure.story("Lista de items")
def test_scroll_to_bottom(self, android_driver):
page = ListPage(android_driver)
with allure.step("Scroll hasta el último elemento"):
found = page.scroll_to_element(AppiumBy.ACCESSIBILITY_ID, "last_item")
assert found, "No se encontró el último elemento tras 5 swipes"For detailed CI/CD pipeline code, read the reference files:
references/ci_github_actions.md (incluye paso de Allure)references/ci_jenkins.md (incluye publicación de Allure)references/appium_inspector.mdreferences/gestures.md| Error | Likely cause | Fix |
|---|---|---|
Could not find a connected Android device | ADB not seeing device | adb devices, enable USB debugging |
An unknown server-side error occurred | Driver not installed | appium driver install uiautomator2 |
Session not created: No matching capabilities | Wrong caps key | Use appium: prefix for non-W3C caps |
Element not found / timeout | Locator wrong or timing | Use Appium Inspector to re-check locator |
WebDriverException: Message: unknown error | App crashed | Check device logs: adb logcat |
iOS xcodebuild fails | Provisioning/Xcode mismatch | Check Xcode version + appium driver update xcuitest |
ACCESSIBILITY_ID — most stable, cross-platformID (resource-id on Android, accessibility id on iOS)XPATH — last resort, fragile-android uiautomator / -ios predicate string — platform-specific power locatorsfrom appium import webdriver
from appium.options import AppiumOptions
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
# Gestures W3C
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
# Allure
import allure~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.