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:
- Appium 2.x (the server):
npm install -g appium
appium --version # Should show 2.x- 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- 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- 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 devicesProject 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-devOr 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
contentDescriptionorandroid: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=foregroundMobile 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.