Skip to content
End To End Tester

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
// 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:

typescript
// 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

typescript
// 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

typescript
// 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.

typescript
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.

typescript
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.

yaml
- uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 35
    arch: x86_64
    script: npm run test:android

Real-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

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?