SauceLabs Appium: Mobile App Testing on Real Devices and Emulators
SauceLabs Real Device Cloud lets you run Appium tests on physical iOS and Android devices without owning or managing them. You upload your app binary via the SauceLabs API, specify device capabilities, and Appium tests run on the target device. This post covers the real devices vs emulators tradeoff, iOS and Android capability setup, app upload, parallel device testing, and common pitfalls.
Real Devices vs Emulators on SauceLabs
SauceLabs offers two mobile testing options:
| Feature | Real Device Cloud | Emulators/Simulators |
|---|---|---|
| Hardware | Physical phones/tablets | Virtualized via software |
| iOS support | Yes | iOS Simulator only |
| Android support | Yes | Android Emulator |
| Speed | Slower (device availability queue) | Faster startup |
| Fidelity | Camera, GPS, NFC, biometrics | Limited sensor support |
| Cost | Higher concurrency cost | Lower |
| Best for | Pre-release validation, crash testing | Development, regression |
For most teams: use emulators during development and CI, real devices for release sign-off and bug reproduction.
Uploading Your App
Before running tests, upload your app binary to SauceLabs App Storage:
# Upload iOS app (.ipa or .app.zip)
curl -u "$SAUCE_USERNAME:$SAUCE_ACCESS_KEY" \
-X POST \
-H "Content-Type: application/octet-stream" \
"https://api.us-west-1.saucelabs.com/v1/storage/upload" \
--data-binary @MyApp.ipa \
-F "name=MyApp.ipa"
# Upload Android app (.apk or .aab)
curl -u "$SAUCE_USERNAME:$SAUCE_ACCESS_KEY" \
-X POST \
-H "Content-Type: application/octet-stream" \
"https://api.us-west-1.saucelabs.com/v1/storage/upload" \
--data-binary @MyApp.apk \
-F "name=MyApp.apk"The response returns a storage:filename=MyApp.ipa reference you use in capabilities. Or reference by file ID: storage:fileId=abc123.
List uploaded apps:
curl -u "$SAUCE_USERNAME:$SAUCE_ACCESS_KEY" \
"https://api.us-west-1.saucelabs.com/v1/storage/files"Android Capabilities
Real Device
from appium import webdriver
desired_caps = {
"platformName": "Android",
"appium:deviceName": "Samsung Galaxy S23",
"appium:platformVersion": "13",
"appium:app": "storage:filename=MyApp.apk",
"appium:automationName": "UiAutomator2",
"sauce:options": {
"username": os.environ["SAUCE_USERNAME"],
"accessKey": os.environ["SAUCE_ACCESS_KEY"],
"name": "Android Login Test",
"build": "1.2.0",
"deviceType": "phone",
}
}
driver = webdriver.Remote(
command_executor="https://ondemand.us-west-1.saucelabs.com/wd/hub",
desired_capabilities=desired_caps
)Android Emulator
desired_caps = {
"platformName": "Android",
"appium:deviceName": "Android GoogleAPI Emulator",
"appium:platformVersion": "13.0",
"appium:app": "storage:filename=MyApp.apk",
"appium:automationName": "UiAutomator2",
"sauce:options": {
"username": os.environ["SAUCE_USERNAME"],
"accessKey": os.environ["SAUCE_ACCESS_KEY"],
"name": "Android Emulator Test",
}
}iOS Capabilities
Real Device (requires signed .ipa)
desired_caps = {
"platformName": "iOS",
"appium:deviceName": "iPhone 15",
"appium:platformVersion": "17",
"appium:app": "storage:filename=MyApp.ipa",
"appium:automationName": "XCUITest",
"sauce:options": {
"username": os.environ["SAUCE_USERNAME"],
"accessKey": os.environ["SAUCE_ACCESS_KEY"],
"name": "iOS Login Test",
"build": "1.2.0",
"resigningEnabled": True, # SauceLabs re-signs with their provisioning profile
}
}iOS Simulator
desired_caps = {
"platformName": "iOS",
"appium:deviceName": "iPhone 15 Simulator",
"appium:platformVersion": "17.4",
"appium:app": "storage:filename=MyApp.zip", # .app.zip for simulators
"appium:automationName": "XCUITest",
"sauce:options": {
"username": os.environ["SAUCE_USERNAME"],
"accessKey": os.environ["SAUCE_ACCESS_KEY"],
"name": "iOS Simulator Test",
}
}Writing Appium Tests
A complete test using pytest and Appium:
import os
import pytest
from appium import webdriver
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
SAUCE_URL = "https://ondemand.us-west-1.saucelabs.com/wd/hub"
@pytest.fixture
def android_driver():
caps = {
"platformName": "Android",
"appium:deviceName": "Android GoogleAPI Emulator",
"appium:platformVersion": "13.0",
"appium:app": "storage:filename=MyApp.apk",
"appium:automationName": "UiAutomator2",
"sauce:options": {
"username": os.environ["SAUCE_USERNAME"],
"accessKey": os.environ["SAUCE_ACCESS_KEY"],
"name": pytest.current_test if hasattr(pytest, "current_test") else "Test",
}
}
driver = webdriver.Remote(SAUCE_URL, caps)
yield driver
driver.execute_script("sauce:job-result=" + ("passed" if not hasattr(driver, "_test_failed") else "failed"))
driver.quit()
def test_login_flow(android_driver):
wait = WebDriverWait(android_driver, 15)
# Find and fill username
username_field = wait.until(
EC.presence_of_element_located((AppiumBy.ACCESSIBILITY_ID, "username_input"))
)
username_field.send_keys("testuser@example.com")
# Fill password
password_field = android_driver.find_element(AppiumBy.ACCESSIBILITY_ID, "password_input")
password_field.send_keys("secretpassword")
# Tap login button
login_btn = android_driver.find_element(AppiumBy.ACCESSIBILITY_ID, "login_button")
login_btn.tap([(login_btn.location["x"], login_btn.location["y"])], 500)
# Assert home screen appears
home_header = wait.until(
EC.presence_of_element_located((AppiumBy.ACCESSIBILITY_ID, "home_header"))
)
assert home_header.is_displayed()Parallel Device Testing
Run the same test across multiple devices simultaneously using pytest-xdist:
# conftest.py
import pytest
DEVICE_MATRIX = [
{"deviceName": "Samsung Galaxy S23", "platformVersion": "13"},
{"deviceName": "Google Pixel 7", "platformVersion": "13"},
{"deviceName": "Samsung Galaxy A54", "platformVersion": "13"},
{"deviceName": "OnePlus 11", "platformVersion": "13"},
]
def pytest_generate_tests(metafunc):
if "device_config" in metafunc.fixturenames:
metafunc.parametrize("device_config", DEVICE_MATRIX)pytest tests/ -n 4 # 4 parallel device sessionsDynamic Device Allocation
Instead of naming a specific device, let SauceLabs pick any available device matching your criteria:
desired_caps = {
"platformName": "Android",
"appium:deviceName": ".*Galaxy.*", # Regex — any Galaxy device
"appium:platformVersion": "13",
# No specific model required
}This reduces wait time when specific devices are in use.
Common Issues
App not found: The storage:filename= reference is case-sensitive and must match the filename used during upload exactly.
iOS re-signing failures: If your .ipa has entitlements that conflict with SauceLabs' provisioning profile, enable resigningEnabled: true in sauce:options. For apps using push notifications or HealthKit, contact SauceLabs support.
Session not created (real device): Device may be in use. Set deviceType: "phone" and use dynamic device allocation (regex deviceName) to reduce wait.
XCUITest agent crash on iOS 17: Use appium:automationName: XCUITest and ensure your Appium version supports iOS 17. SauceLabs uses Appium 2.x on their infrastructure.
Test results show "complete" without pass/fail: Add driver.execute_script("sauce:job-result=passed") in your teardown.
App Storage Management
Apps expire after 60 days by default. Automate upload in CI and track the file ID:
UPLOAD_RESPONSE=$(curl -s -u "$SAUCE_USERNAME:$SAUCE_ACCESS_KEY" \
-X POST \
"https://api.us-west-1.saucelabs.com/v1/storage/upload" \
--data-binary @build/MyApp.apk \
-F "name=MyApp-${BUILD_NUMBER}.apk")
FILE_ID=$(echo $UPLOAD_RESPONSE | jq -r '.item.id')
echo "APP_FILE_ID=$FILE_ID" >> $GITHUB_ENVThen in capabilities: "appium:app": f"storage:fileId={os.environ['APP_FILE_ID']}".