Appium
One WebDriver-based API across iOS and Android — how the drivers map onto XCUITest and UIAutomator, locator strategies, and the cost of cross-platform tests.
2 min read · updated 19 September 2026
Appium speaks the W3C WebDriver protocol — the same one
Selenium uses — and translates it into native
automation calls: XCUITest on iOS, UIAutomator2 or Espresso on
Android.
One API, two platforms, in any language with a WebDriver binding.
#Session setup
// TypeScript, WebdriverIO. The capabilities are where the platform
// differences live; the test body is usually shared.
import { remote } from 'webdriverio';
const ios = {
platformName: 'iOS',
'appium:automationName': 'XCUITest',
'appium:deviceName': 'iPhone 16',
'appium:platformVersion': '18.0',
'appium:app': '/build/App.app'
};
const android = {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': 'Pixel_8_API_35',
'appium:app': '/build/app-debug.apk',
'appium:appWaitActivity': '*'
};
const driver = await remote({
hostname: '127.0.0.1',
port: 4723,
capabilities: process.env.PLATFORM === 'ios' ? ios : android
});#Locators
In order of preference:
// 1. Accessibility id — the same attribute name on both platforms.
// iOS: view.accessibilityIdentifier = "checkout-pay"
// Android: android:contentDescription="checkout-pay"
await driver.$('~checkout-pay').click();
// 2. Platform-native queries, when you need them
await driver.$('android=new UiSelector().text("Pay")').click();
await driver.$('-ios predicate string:label == "Pay"').click();
// 3. Class chains (iOS) — fast, still structural
await driver.$('-ios class chain:**/XCUIElementTypeButton[`label == "Pay"`]').click();
// 4. XPath — slowest, most brittle. Last resort.
await driver.$('//XCUIElementTypeButton[@name="Pay"]').click();Accessibility ids are the whole game. Adding them is a small change in the app and it makes the suite cross-platform, fast and robust in one move. It also improves the app's actual accessibility, which is a rare case of a testability change with an independent user benefit. See accessibility testing.
XPath in Appium is genuinely slow — it serialises the entire view hierarchy to XML for every query. On a complex screen that is hundreds of milliseconds per lookup.
#Waiting
// Explicit, condition-based. Never a fixed sleep.
await driver.$('~order-confirmation').waitForDisplayed({ timeout: 10_000 });
await driver.waitUntil(
async () => (await driver.$('~cart-badge').getText()) === '1',
{ timeout: 5_000, timeoutMsg: 'the cart badge never reached 1' }
);The same rule as everywhere: wait for a condition, not a duration. See flaky tests.
#Gestures
// W3C actions — the portable way
await driver.performActions([{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: 200, y: 900 },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 200 },
{ type: 'pointerMove', duration: 400, x: 200, y: 300 }, // swipe up
{ type: 'pointerUp', button: 0 }
]
}]);
// Or the driver-specific shortcuts, which are far more readable
await driver.execute('mobile: swipe', { direction: 'up' });
await driver.execute('mobile: scroll', { strategy: 'accessibility id', selector: 'pay' });#Contexts: native and webview
Hybrid apps switch between native chrome and an embedded web view.
const contexts = await driver.getContexts(); // ['NATIVE_APP', 'WEBVIEW_com.example']
await driver.switchContext(contexts.find((c) => c.startsWith('WEBVIEW'))!);
await driver.$('#checkout-form input[name=card]').setValue('4242424242424242');
await driver.switchContext('NATIVE_APP');
await driver.$('~done').click();#Structure
The page object model applies directly. Screen objects hold the locators and platform differences; tests hold the flow.
export class CheckoutScreen {
private readonly pay = '~checkout-pay';
// The one place a platform difference is allowed to live.
private get cardField() {
return driver.isIOS ? '~card-number' : 'android=new UiSelector().resourceId("card_number")';
}
async payWith(card: string) {
await driver.$(this.cardField).setValue(card);
await driver.$(this.pay).click();
}
}#In CI
Android is straightforward — an emulator runs on a Linux runner. iOS needs macOS, which means a macOS runner and roughly ten times the minute cost on most hosted CI.
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
script: npm run test:androidReal-device coverage — the last mile of iOS and Android testing — generally means a device cloud: BrowserStack, Sauce Labs, LambdaTest or AWS Device Farm. All of them speak Appium, which is the main practical argument for using it.
#The honest trade
Appium is slower and more failure-prone than XCUITest or Espresso natively, because every command crosses an HTTP boundary and a translation layer. What you buy is one suite, one language, and one set of skills across two platforms.
That trade is good when one team owns both apps and the journeys are the same. It is a poor trade when the apps are built by separate native teams who would each rather write tests in their own toolchain — in which case run native suites per platform and keep Appium, if at all, for a handful of cross-platform smoke journeys.
Common questions
- Is Appium worth it compared to native test frameworks?
- It is worth it when you have one team testing both platforms and the flows are genuinely the same. Native frameworks — XCUITest and Espresso — are faster and more stable, so if the two apps have separate teams, the native route usually wins.
- Why are Appium tests slow?
- Every command is an HTTP round trip to the Appium server, which forwards it to the platform driver, which talks to the app. Reducing the number of commands is the main lever — prefer a single accessibility-id lookup over walking a hierarchy with XPath.
- What locator strategy should I use?
- Accessibility id, above everything else. It maps to accessibilityIdentifier on iOS and content-desc on Android, works identically on both platforms, and does not break when the layout changes. XPath is the slowest and most fragile option and should be a last resort.
Runnable samples for this page
- reference only
reference/tools/appiumreference only
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- iOS App TestingXCTest, Swift Testing, XCUITest and the accessibility identifiers that make UI automation possible — plus what to run on a simulator and what needs a device.
- Android App TestingJUnit and Robolectric for local tests, Espresso and Compose for instrumented UI, and the emulator strategy that keeps an Android suite affordable in CI.
- Selenium WebDriverThe W3C standard for browser automation — where it still wins, the waiting model that decides whether a suite is stable, and Grid for scale.
- Flutter TestingFlutter renders to a canvas with no native accessibility tree, so it brings its own three-layer test harness — unit, widget and integration — plus golden files.
- The Page Object ModelEncapsulating a screen behind a class so tests describe intent rather than selectors — what it fixes, the god-object failure mode, and modern alternatives.
- Screen RecordingsVideo of a test run — when it is worth the storage, how to configure it so it costs nothing on green runs, and why a trace is usually better.
- Traces and Debugging CI FailuresA trace records every action, request, console message and DOM snapshot of a run — the single artefact that turns an unreproducible CI failure into a five-minute diagnosis.
- Testing Avalonia ApplicationsAvalonia's headless test platform runs real UI tests in CI in milliseconds with no display server — the most testable desktop stack in .NET.