Appium with WebdriverIO: Modern Mobile Test Automation Setup

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 uiautomator2

Configuration

// 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();
}
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=iOS

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

Read more

Start now free