WebdriverIO Mobile Testing with Appium Integration

WebdriverIO Mobile Testing with Appium Integration

One of WebdriverIO's most powerful capabilities is its integration with Appium for mobile testing. Using the same framework for browser automation and mobile testing means your team shares knowledge, tooling, and even some code across platforms. This guide covers setting up WebdriverIO with Appium to test iOS and Android applications.

Why WebdriverIO + Appium?

Teams testing across web and mobile often maintain separate automation codebases — Playwright for web, Appium directly for mobile. This duplication means separate learning curves, different CI configurations, and duplicated page object patterns.

WebdriverIO bridges this gap. It speaks the WebDriver protocol natively, and Appium is a WebDriver server for mobile apps. The same WebdriverIO API you use for browser automation works for mobile — with Appium-specific capabilities and mobile-specific element strategies.

Benefits of the combination:

  • Unified framework — one tool for web, iOS, and Android
  • Shared utilities — test data helpers, reporters, CI configurations
  • Consistent API$(), $$(), click, setValue work on both
  • Reusable page objects — base classes shared across platforms
  • Single CI pipeline — web and mobile tests in one workflow

Prerequisites

Before starting, install:

  1. Appium 2.x (the server):
npm install -g appium
appium --version  # Should show 2.x
  1. Appium drivers for your target platforms:
# iOS (macOS only)
appium driver install xcuitest

<span class="hljs-comment"># Android
appium driver install uiautomator2

<span class="hljs-comment"># Verify drivers
appium driver list --installed
  1. iOS prerequisites (macOS only):
# Xcode command line tools
xcode-select --install

<span class="hljs-comment"># Carthage (for WebDriverAgent)
brew install carthage

<span class="hljs-comment"># ios-deploy (for real devices)
npm install -g ios-deploy
  1. Android prerequisites:
# Android Studio + SDK
<span class="hljs-comment"># Set environment variables:
<span class="hljs-built_in">export ANDROID_HOME=<span class="hljs-variable">$HOME/Library/Android/sdk
<span class="hljs-built_in">export PATH=<span class="hljs-variable">$PATH:<span class="hljs-variable">$ANDROID_HOME/emulator:<span class="hljs-variable">$ANDROID_HOME/tools:<span class="hljs-variable">$ANDROID_HOME/platform-tools

<span class="hljs-comment"># Verify ADB sees your device/emulator
adb devices

Project Setup

Install WebdriverIO with Appium support:

mkdir mobile-tests && <span class="hljs-built_in">cd mobile-tests
npm init -y
npm install webdriverio @wdio/cli --save-dev
npm install @wdio/appium-service --save-dev

Or use the WebdriverIO setup wizard and select "Mobile" when prompted.

Configuration

Create separate config files for iOS and Android:

iOS Configuration

// wdio.ios.conf.js
export const config = {
  // Appium server (local)
  hostname: '127.0.0.1',
  port: 4723,
  path: '/',

  // Spec files
  specs: ['./test/specs/ios/**/*.e2e.js', './test/specs/shared/**/*.e2e.js'],

  capabilities: [{
    platformName: 'iOS',
    'appium:automationName': 'XCUITest',
    
    // Test against a simulator
    'appium:deviceName': 'iPhone 15',
    'appium:platformVersion': '17.0',
    'appium:simulatorStartupTimeout': 120000,
    
    // App path (build first)
    'appium:app': './build/MyApp.app',
    
    // Or bundle ID for installed apps
    // 'appium:bundleId': 'com.mycompany.myapp',
    
    // Auto-reset between tests
    'appium:noReset': false,
    'appium:fullReset': false,
  }],

  // Use Appium service to auto-start/stop server
  services: [['appium', {
    command: 'appium',
    args: {
      address: '127.0.0.1',
      port: 4723,
      relaxedSecurity: true,
      log: './logs/appium.log',
    }
  }]],

  framework: 'mocha',
  mochaOpts: {
    ui: 'bdd',
    timeout: 120000,  // Mobile tests need more time
  },

  reporters: ['spec'],

  waitforTimeout: 30000,
  connectionRetryTimeout: 300000,
  connectionRetryCount: 1,
};

Android Configuration

// wdio.android.conf.js
export const config = {
  hostname: '127.0.0.1',
  port: 4723,
  path: '/',

  specs: ['./test/specs/android/**/*.e2e.js', './test/specs/shared/**/*.e2e.js'],

  capabilities: [{
    platformName: 'Android',
    'appium:automationName': 'UiAutomator2',
    
    'appium:deviceName': 'Pixel 7 API 34',  // Emulator AVD name
    'appium:platformVersion': '14',
    
    'appium:app': './build/app-debug.apk',
    
    // Or package + activity for installed apps
    // 'appium:appPackage': 'com.mycompany.myapp',
    // 'appium:appActivity': '.MainActivity',
    
    'appium:noReset': false,
    'appium:autoGrantPermissions': true,  // Auto-allow permissions
    'appium:newCommandTimeout': 60,
  }],

  services: [['appium', {
    command: 'appium',
    args: {
      address: '127.0.0.1',
      port: 4723,
      log: './logs/appium.log',
    }
  }]],

  framework: 'mocha',
  mochaOpts: {
    ui: 'bdd',
    timeout: 120000,
  },

  waitforTimeout: 30000,
};

Add scripts to package.json:

{
  "scripts": {
    "test:ios": "wdio run wdio.ios.conf.js",
    "test:android": "wdio run wdio.android.conf.js",
    "test:web": "wdio run wdio.conf.js"
  }
}

Mobile Element Selection

Mobile apps use different element strategies than web browsers. Understanding selectors is critical.

iOS Selectors

// Accessibility ID (preferred — works on both iOS and Android)
const loginButton = await $('~loginButton');  // ~ prefix = accessibility ID

// iOS class chain (like XPath but faster)
const header = await $('-ios class chain:**/XCUIElementTypeStaticText[`label == "Welcome"`]');

// iOS predicate string (fast, flexible)
const emailField = await $('-ios predicate string:type == "XCUIElementTypeTextField" AND value == "Email"');

// XPath (slowest, avoid if possible)
const button = await $('//XCUIElementTypeButton[@name="Submit"]');

// Accessibility label
const cell = await $('~product-cell-0');

Android Selectors

// Accessibility ID (same syntax as iOS)
const loginButton = await $('~login_button');

// UiSelector (UIAutomator)
const priceText = await $('android=new UiSelector().resourceId("com.app:id/price").instance(0)');

// Resource ID  
const searchInput = await $('id:com.mycompany.myapp:id/search_input');

// Content description
const backButton = await $('~Navigate up');

// XPath (avoid in Android — extremely slow)
const text = await $('//android.widget.TextView[@text="Products"]');

Cross-Platform Strategy

Design selectors that work on both platforms by using accessibility IDs:

In your app code:

  • iOS: set accessibilityIdentifier
  • Android: set contentDescription or android:tag

In tests:

// Works on both iOS and Android
const loginButton = await $('~loginButton');
const emailField = await $('~emailInput');

Mobile-Specific Actions

Mobile apps require gestures and interactions not available in browser automation:

Tap and Touch

// Simple tap
await $('~submitButton').click();

// Tap at coordinates
await driver.touchAction([
  { action: 'tap', x: 200, y: 400 }
]);

// Long press
await driver.touchAction([
  { action: 'press', x: 200, y: 400 },
  { action: 'wait', ms: 1000 },
  { action: 'release' }
]);

Scrolling

// Scroll down
await driver.execute('mobile: scroll', { direction: 'down' });

// Scroll to element (iOS)
await driver.execute('mobile: scroll', {
  direction: 'down',
  predicateString: 'label == "Submit"'
});

// Swipe gesture
const { width, height } = await driver.getWindowSize();
await driver.touchAction([
  { action: 'press', x: width * 0.5, y: height * 0.8 },
  { action: 'moveTo', x: width * 0.5, y: height * 0.2 },
  { action: 'release' }
]);

// Scroll element into view
await $('~submitButton').scrollIntoView();

Text Input

// Type text
await $('~emailInput').setValue('user@example.com');

// Clear and type
await $('~searchInput').clearValue();
await $('~searchInput').addValue('WebdriverIO');

// Dismiss keyboard
await driver.hideKeyboard();

// Press keyboard keys (iOS)
await driver.execute('mobile: pressButton', { name: 'return' });

App Control

// Background the app
await driver.background(3);  // Background for 3 seconds

// Bring app to foreground
await driver.activateApp('com.mycompany.myapp');

// Reset app state
await driver.reset();  // clearAppData + launch

// Close and reopen
await driver.terminateApp('com.mycompany.myapp');
await driver.activateApp('com.mycompany.myapp');

// Get app state
const state = await driver.queryAppState('com.mycompany.myapp');
// 0=not installed, 1=not running, 3=background, 4=foreground

Mobile Page Objects

Extend the Page Object pattern for mobile. Use a base class that handles platform differences:

// test/pageobjects/mobile/MobilePage.js
import { Page } from '../Page.js';

export class MobilePage extends Page {
  get platform() {
    return driver.capabilities.platformName?.toLowerCase();
  }

  get isIOS() { return this.platform === 'ios'; }
  get isAndroid() { return this.platform === 'android'; }

  /**
   * Platform-aware selector
   */
  byPlatform(iosSelector, androidSelector) {
    return this.isIOS ? $(iosSelector) : $(androidSelector);
  }

  /**
   * Scroll until element is visible
   */
  async scrollToElement(element, maxScrolls = 10) {
    let scrollCount = 0;
    while (!(await element.isDisplayed()) && scrollCount < maxScrolls) {
      await driver.execute('mobile: scroll', { direction: 'down' });
      scrollCount++;
    }
    if (!(await element.isDisplayed())) {
      throw new Error(`Element not found after ${maxScrolls} scrolls`);
    }
    return element;
  }

  /**
   * Wait for animation to complete
   */
  async waitForAnimation(ms = 500) {
    await browser.pause(ms);
  }
}
// test/pageobjects/mobile/LoginPage.js
import { MobilePage } from './MobilePage.js';

export class MobileLoginPage extends MobilePage {
  get emailField() { return $('~emailInput'); }
  get passwordField() { return $('~passwordInput'); }
  get loginButton() { return $('~loginButton'); }
  get errorMessage() { return $('~loginError'); }
  
  // Platform-specific elements
  get forgotPasswordLink() {
    return this.byPlatform(
      '~forgotPassword',
      'id:com.app:id/forgot_password'
    );
  }

  async login(email, password) {
    await this.emailField.click();
    await this.emailField.setValue(email);
    
    await this.passwordField.click();
    await this.passwordField.setValue(password);
    
    await driver.hideKeyboard();
    await this.loginButton.click();
  }

  async waitForLoginComplete(timeout = 10000) {
    await browser.waitUntil(async () => {
      try {
        // Wait for login screen to disappear
        return !(await $('~loginButton').isDisplayed());
      } catch {
        return true;
      }
    }, { timeout });
  }
}

export const mobileLoginPage = new MobileLoginPage();

Writing Cross-Platform Tests

Structure tests that run on both iOS and Android:

// test/specs/shared/auth.e2e.js
import { mobileLoginPage } from '../../pageobjects/mobile/LoginPage.js';

describe('Authentication', () => {
  beforeEach(async () => {
    await driver.reset();  // Fresh app state
  });

  it('should login with valid credentials', async () => {
    await mobileLoginPage.login('test@example.com', 'password123');
    await mobileLoginPage.waitForLoginComplete();
    
    // Verify logged in
    const homeScreen = await $('~homeScreen');
    await expect(homeScreen).toBeDisplayed();
  });

  it('should show error for invalid credentials', async () => {
    await mobileLoginPage.login('wrong@example.com', 'badpassword');
    
    await expect(mobileLoginPage.errorMessage).toBeDisplayed();
    const errorText = await mobileLoginPage.errorMessage.getText();
    expect(errorText).toContain('Invalid credentials');
  });
});

Real Device Testing

Simulators/emulators work for development but real device testing catches device-specific issues:

iOS Real Device

capabilities: [{
  platformName: 'iOS',
  'appium:automationName': 'XCUITest',
  'appium:udid': 'DEVICE_UDID',  // Get from: xcrun instruments -s devices
  'appium:bundleId': 'com.mycompany.myapp',
  'appium:xcodeOrgId': 'YOUR_TEAM_ID',
  'appium:xcodeSigningId': 'iPhone Developer',
}]

Android Real Device

capabilities: [{
  platformName: 'Android',
  'appium:automationName': 'UiAutomator2',
  'appium:udid': 'DEVICE_SERIAL',  // Get from: adb devices
  'appium:appPackage': 'com.mycompany.myapp',
  'appium:appActivity': '.MainActivity',
}]

For CI with real devices, cloud services like BrowserStack or Sauce Labs provide device farms that integrate with WebdriverIO.

CI Integration for Mobile

# .github/workflows/mobile-tests.yml
name: Mobile E2E Tests

on:
  push:
    branches: [main]

jobs:
  android:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci

      - name: Enable KVM for Android emulator
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Start Android emulator
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          target: google_apis
          arch: x86_64
          profile: pixel_6
          avd-name: Pixel_6_API_34
          script: npm run test:android
        env:
          ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}

Debugging Mobile Tests

Mobile automation is harder to debug than web. Key techniques:

// Take screenshots at key steps
await driver.saveScreenshot('./debug-screenshot.png');

// Get page source (accessibility tree)
const source = await driver.getPageSource();
console.log(source);

// Log element attributes
const element = await $('~loginButton');
console.log('Displayed:', await element.isDisplayed());
console.log('Enabled:', await element.isEnabled());
console.log('Text:', await element.getText());

Use Appium Inspector (desktop app) to visually explore your app's element hierarchy and test selectors before writing tests.

Summary

WebdriverIO + Appium gives you a unified testing framework across web, iOS, and Android. The same API, same page object patterns, same reporters, and same CI tooling.

The investment in learning WebdriverIO for web automation pays dividends when extending to mobile — your team's existing knowledge transfers directly. The key differences are:

  • Mobile-specific capabilities in config
  • Accessibility ID selectors instead of CSS
  • Mobile gestures (scroll, swipe, tap)
  • App lifecycle control (reset, background, terminate)

For teams doing browser automation and mobile testing, this unified approach reduces tooling complexity and makes cross-platform coverage achievable without doubling your test infrastructure.

Read more