Appium with WebdriverIO: Modern Mobile Test Automation Setup
WebdriverIO's Appium integration is the smoothest way to write mobile automation tests in JavaScript. The @wdio/appium-service handles starting and stopping Appium automatically, the configuration is declarative, and the same test code works against iOS and Android with minimal platform-specific branches.
This guide covers setting up a complete mobile test project with WebdriverIO + Appium 2.
Project Setup
mkdir mobile-tests && cd mobile-tests
npm init wdio@latest .Select:
- Type of testing: Mobile - iOS/Android App Testing
- Test runner: Mocha (or your preference)
- Appium environment: Local (Appium server started by WDIO)
- Reporters: Spec
- Services: Appium
This installs dependencies and creates wdio.conf.js.
Manual Setup
npm install --save-dev \
@wdio/cli \
@wdio/local-runner \
@wdio/mocha-framework \
@wdio/spec-reporter \
@wdio/appium-service
npm install -g appium
appium driver install xcuitest
appium driver install uiautomator2Configuration
// wdio.conf.js
import path from "path";
export const config = {
runner: "local",
specs: ["./tests/**/*.spec.js"],
capabilities: [
// iOS configuration
{
platformName: "iOS",
"appium:deviceName": "iPhone 15",
"appium:platformVersion": "17.0",
"appium:automationName": "XCUITest",
"appium:app": path.join(process.cwd(), "apps/MyApp.app"),
"appium:newCommandTimeout": 90,
"appium:wdaLaunchTimeout": 120000,
"appium:simulatorStartupTimeout": 120000,
},
// Android configuration
{
platformName: "Android",
"appium:deviceName": "Pixel_7_API_34",
"appium:automationName": "UiAutomator2",
"appium:app": path.join(process.cwd(), "apps/MyApp.apk"),
"appium:newCommandTimeout": 90,
"appium:avd": "Pixel_7_API_34",
"appium:avdLaunchTimeout": 120000,
}
],
// Run one capability at a time (2 = iOS and Android in parallel)
maxInstances: 1,
framework: "mocha",
mochaOpts: {
timeout: 60000,
},
reporters: ["spec"],
services: [
[
"appium",
{
command: "appium",
args: {
port: 4723,
relaxedSecurity: false,
},
},
],
],
logLevel: "info",
};Writing Cross-Platform Tests
Use capability detection to handle platform differences:
// tests/helpers/platform.js
export function isIOS() {
return driver.capabilities.platformName === "iOS";
}
export function isAndroid() {
return driver.capabilities.platformName === "Android";
}
export function getSelector(ios, android) {
return isIOS() ? ios : android;
}// tests/login.spec.js
import { isIOS, getSelector } from "./helpers/platform.js";
describe("Login Flow", () => {
before(async () => {
// Wait for app to load
const splashScreen = await $("~splash-screen");
await splashScreen.waitForDisplayed({ timeout: 10000, reverse: true });
});
it("should display login screen on launch", async () => {
const loginTitle = await $(
getSelector("~login-title", "//android.widget.TextView[@text='Sign In']")
);
await expect(loginTitle).toBeDisplayed();
});
it("should login with valid credentials", async () => {
const emailField = await $(
getSelector("~email-input", "//android.widget.EditText[@hint='Email']")
);
const passwordField = await $(
getSelector("~password-input", "//android.widget.EditText[@hint='Password']")
);
const loginButton = await $("~login-button");
await emailField.setValue("test@example.com");
await passwordField.setValue("password123");
await loginButton.click();
// Verify successful login
const dashboard = await $("~dashboard-screen");
await dashboard.waitForDisplayed({ timeout: 15000 });
await expect(dashboard).toBeDisplayed();
});
it("should show error for invalid credentials", async () => {
const emailField = await $(
getSelector("~email-input", "//android.widget.EditText[@hint='Email']")
);
const passwordField = await $(
getSelector("~password-input", "//android.widget.EditText[@hint='Password']")
);
await emailField.setValue("wrong@example.com");
await passwordField.setValue("wrongpassword");
await $("~login-button").click();
const errorMessage = await $("~error-message");
await errorMessage.waitForDisplayed({ timeout: 5000 });
await expect(errorMessage).toHaveText("Invalid email or password");
});
});Page Object Model for Mobile
// tests/pages/LoginPage.js
class LoginPage {
get emailField() {
return $("~email-input");
}
get passwordField() {
return $("~password-input");
}
get loginButton() {
return $("~login-button");
}
get errorMessage() {
return $("~error-message");
}
async login(email, password) {
await this.emailField.setValue(email);
await this.passwordField.setValue(password);
await this.loginButton.click();
}
async waitForVisible() {
await this.emailField.waitForDisplayed({ timeout: 10000 });
}
}
export default new LoginPage();// tests/pages/DashboardPage.js
class DashboardPage {
get headerTitle() {
return $("~dashboard-header");
}
get menuButton() {
return $("~menu-button");
}
async waitForVisible() {
await this.headerTitle.waitForDisplayed({ timeout: 15000 });
}
async isVisible() {
return this.headerTitle.isDisplayed();
}
}
export default new DashboardPage();// tests/login.spec.js (with page objects)
import LoginPage from "./pages/LoginPage.js";
import DashboardPage from "./pages/DashboardPage.js";
describe("Authentication", () => {
beforeEach(async () => {
await LoginPage.waitForVisible();
});
it("should navigate to dashboard after successful login", async () => {
await LoginPage.login("user@example.com", "validpassword");
await DashboardPage.waitForVisible();
await expect(DashboardPage.headerTitle).toBeDisplayed();
});
});Gestures
// tests/helpers/gestures.js
export async function swipeLeft(element) {
const location = await element.getLocation();
const size = await element.getSize();
await driver.action("pointer")
.move({ x: location.x + size.width * 0.8, y: location.y + size.height / 2 })
.down()
.move({ x: location.x + size.width * 0.2, y: location.y + size.height / 2 })
.up()
.perform();
}
export async function scrollDown() {
const { height, width } = await driver.getWindowSize();
await driver.action("pointer")
.move({ x: width / 2, y: height * 0.7 })
.down()
.move({ x: width / 2, y: height * 0.3 })
.up()
.perform();
}
export async function longPress(element, durationMs = 1500) {
const location = await element.getLocation();
const size = await element.getSize();
const x = location.x + size.width / 2;
const y = location.y + size.height / 2;
await driver.action("pointer")
.move({ x, y })
.down()
.pause(durationMs)
.up()
.perform();
}Deep Link Testing
it("should handle deep link to product page", async () => {
if (isIOS()) {
await driver.execute("mobile: deepLink", {
url: "myapp://products/123",
bundleId: "com.example.myapp"
});
} else {
await driver.execute("mobile: deepLink", {
url: "myapp://products/123",
package: "com.example.myapp"
});
}
await $("~product-detail-screen").waitForDisplayed({ timeout: 10000 });
const productTitle = await $("~product-title");
await expect(productTitle).toBeDisplayed();
});App State Management in Tests
describe("Onboarding", () => {
// Reset app to clean state before each test
beforeEach(async () => {
if (isIOS()) {
await driver.executeScript("mobile: terminateApp", [{ bundleId: "com.example.myapp" }]);
await driver.executeScript("mobile: activateApp", [{ bundleId: "com.example.myapp" }]);
} else {
await driver.reset();
}
});
it("should show onboarding on fresh install", async () => {
const onboardingScreen = await $("~onboarding-welcome");
await onboardingScreen.waitForDisplayed({ timeout: 10000 });
await expect(onboardingScreen).toBeDisplayed();
});
});Handling Permissions
async function allowPermissions() {
if (isIOS()) {
try {
const allowButton = await $("-ios predicate string:label == 'Allow'");
if (await allowButton.isDisplayed()) {
await allowButton.click();
}
} catch (e) {
// Permission dialog not shown
}
} else {
try {
const allowButton = await $("//android.widget.Button[@text='ALLOW']");
if (await allowButton.isDisplayed()) {
await allowButton.click();
}
} catch (e) {
// Permission dialog not shown
}
}
}
it("should request camera permission", async () => {
await $("~camera-button").click();
await allowPermissions();
await $("~camera-view").waitForDisplayed({ timeout: 5000 });
});CI/CD Integration
# .github/workflows/mobile-tests.yml
name: Mobile Tests
on:
push:
branches: [main]
jobs:
android-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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
- uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
- uses: android-actions/setup-android@v3
- name: Create AVD and start emulator
run: |
echo "no" | avdmanager create avd \
-n test \
-k "system-images;android-34;google_apis;x86_64"
$ANDROID_HOME/emulator/emulator -avd test \
-no-audio -no-window -gpu swiftshader_indirect &
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done'
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Install Appium
run: |
npm install -g appium
appium driver install uiautomator2
- name: Run Android tests
run: |
npx wdio wdio.conf.js --capabilities.0.platformName=Android
ios-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- name: Install Appium
run: |
npm install -g appium
appium driver install xcuitest
- name: Start iOS Simulator
run: |
xcrun simctl boot "iPhone 15" || true
xcrun simctl list devices | grep "iPhone 15"
- name: Run iOS tests
run: npx wdio wdio.conf.js --capabilities.1.platformName=iOSDebugging Flaky Tests
Element not found: Add explicit waits. Never use hard sleeps. Use waitForDisplayed with a timeout.
Stale element reference: Re-query elements that may have been recreated by the app:
// Bad - element may be stale after app state change
const button = await $("~submit-button");
await doSomethingThatUpdatesUI();
await button.click(); // May be stale
// Good - re-query after state change
await doSomethingThatUpdatesUI();
await $("~submit-button").click();Slow iOS Simulator startup: Add "appium:simulatorStartupTimeout": 120000 to capabilities.
Android emulator not ready: Use adb wait-for-device and wait for sys.boot_completed before running tests.
WebdriverIO's Appium integration removes the boilerplate of managing the Appium server, gives you excellent TypeScript support, and integrates with the full WebdriverIO ecosystem of reporters, services, and assertions. The page object pattern keeps tests readable as your test suite grows.