Cross-Platform Mobile Testing Strategy: iOS and Android Without Duplication

Cross-Platform Mobile Testing Strategy: iOS and Android Without Duplication

Mobile teams constantly fight the same battle: test code for iOS and Android is 80% identical but diverges on selectors, gestures, permission dialogs, and platform-specific behaviors. The naive approach is two separate test suites. The right approach is shared test logic with platform adapters.

This guide covers the architectural patterns that let you write cross-platform mobile tests that are actually maintainable.

The Core Problem

A login test on iOS:

await $("-ios predicate string:label == 'Email'").setValue("user@example.com");
await $("-ios predicate string:label == 'Password'").setValue("pass");
await $("-ios predicate string:label == 'Sign In'").click();

The "same" test on Android:

await $("//android.widget.EditText[@hint='Email']").setValue("user@example.com");
await $("//android.widget.EditText[@hint='Password']").setValue("pass");
await $("//android.widget.Button[@text='Sign In']").click();

You're writing the same test twice. And every change to the UI means updating two tests. The solution is abstraction.

Solution 1: Accessibility IDs (Best Approach)

The highest-ROI fix: make your mobile developers add accessibility IDs (testID in React Native, accessibilityIdentifier in iOS, contentDescription in Android). These work on both platforms without any platform branching.

// Works on both iOS and Android
await $("~email-input").setValue("user@example.com");
await $("~password-input").setValue("pass");
await $("~sign-in-button").click();

React Native:

<TextInput testID="email-input" ... />
<Button testID="sign-in-button" title="Sign In" ... />

iOS Swift:

emailTextField.accessibilityIdentifier = "email-input"
signInButton.accessibilityIdentifier = "sign-in-button"

Android:

<EditText android:contentDescription="email-input" ... />
<Button android:contentDescription="sign-in-button" ... />

Negotiate with your mobile developers once: every interactive element gets a testID. You'll eliminate most platform-specific selectors.

Solution 2: Selector Abstraction Layer

For elements you can't control (third-party screens, OS dialogs), use a selector map:

// tests/selectors.js
export const selectors = {
  login: {
    emailInput: {
      ios: "-ios predicate string:type == 'XCUIElementTypeTextField' AND placeholderValue == 'Email'",
      android: "//android.widget.EditText[@hint='Email']",
      common: "~email-input"  // preferred if available
    },
    signInButton: {
      ios: "-ios predicate string:label == 'Sign In'",
      android: "//android.widget.Button[@text='Sign In']",
      common: "~sign-in-button"
    }
  },
  permissions: {
    allowButton: {
      ios: "-ios predicate string:label == 'Allow'",
      android: "//android.widget.Button[@text='ALLOW']"
    }
  }
};

export function getSelector(path) {
  const parts = path.split(".");
  let current = selectors;
  for (const part of parts) {
    current = current[part];
    if (!current) throw new Error(`Selector not found: ${path}`);
  }
  
  // Prefer common (accessibility ID), then platform-specific
  if (current.common) return current.common;
  const platform = driver.capabilities.platformName.toLowerCase();
  return current[platform] || current.ios;
}
// tests/pages/LoginPage.js
import { getSelector } from "../selectors.js";

class LoginPage {
  get emailInput() { return $(getSelector("login.emailInput")); }
  get signInButton() { return $(getSelector("login.signInButton")); }
  
  async login(email, password) {
    await this.emailInput.setValue(email);
    await $(getSelector("login.passwordInput")).setValue(password);
    await this.signInButton.click();
  }
}

Solution 3: Platform Adapters for Behaviors

Some behaviors differ fundamentally between platforms — not just selectors, but entire flows:

// tests/adapters/platform.js

const platformAdapters = {
  ios: {
    async grantPermission(permission) {
      // iOS: click "Allow" in system dialog
      try {
        const allowBtn = await $("-ios predicate string:label == 'Allow'");
        if (await allowBtn.isDisplayed()) await allowBtn.click();
      } catch (e) { /* permission dialog not shown */ }
    },
    
    async openNotificationCenter() {
      await driver.execute("mobile: swipe", { direction: "down", y: 0 });
    },
    
    async setBiometric(enrolled = true) {
      await driver.execute("mobile: enrollBiometric", { isEnabled: enrolled });
    },
    
    async triggerFaceID(success = true) {
      await driver.execute("mobile: sendBiometricMatch", { type: "faceId", match: success });
    }
  },
  
  android: {
    async grantPermission(permission) {
      try {
        const allowBtn = await $(`//android.widget.Button[@text='ALLOW']`);
        if (await allowBtn.isDisplayed()) await allowBtn.click();
      } catch (e) { /* permission dialog not shown */ }
    },
    
    async openNotificationCenter() {
      await driver.execute("mobile: openNotifications");
    },
    
    async setBiometric(enrolled = true) {
      await driver.execute("mobile: fingerPrint", { fingerprintId: 1 });
    },
    
    async triggerFingerprint(success = true) {
      await driver.execute("mobile: fingerPrint", {
        fingerprintId: success ? 1 : 999  // 999 = invalid fingerprint
      });
    }
  }
};

export function getPlatformAdapter() {
  const platform = driver.capabilities.platformName.toLowerCase();
  return platformAdapters[platform];
}
// tests/biometric.spec.js
import { getPlatformAdapter } from "./adapters/platform.js";

describe("Biometric Authentication", () => {
  let platform;
  
  before(() => {
    platform = getPlatformAdapter();
  });
  
  it("should authenticate with biometric on success", async () => {
    await $("~use-biometric-button").click();
    
    // Platform-specific biometric trigger
    if (driver.capabilities.platformName === "iOS") {
      await platform.triggerFaceID(true);
    } else {
      await platform.triggerFingerprint(true);
    }
    
    await $("~dashboard-screen").waitForDisplayed({ timeout: 10000 });
  });
  
  it("should show error on biometric failure", async () => {
    await $("~use-biometric-button").click();
    
    if (driver.capabilities.platformName === "iOS") {
      await platform.triggerFaceID(false);
    } else {
      await platform.triggerFingerprint(false);
    }
    
    await $("~biometric-error-message").waitForDisplayed({ timeout: 5000 });
  });
});

Shared Test Data Management

Use a data factory that creates platform-appropriate test data:

// tests/data/factories.js
export const userFactory = {
  valid: () => ({
    email: `test-${Date.now()}@example.com`,
    password: "ValidPassword123!",
    name: "Test User"
  }),
  
  invalid: {
    badEmail: { email: "not-an-email", password: "pass123" },
    shortPassword: { email: "test@example.com", password: "123" },
    empty: { email: "", password: "" }
  }
};

CI/CD for Both Platforms

Structure your CI to run iOS and Android in parallel:

# .github/workflows/mobile.yml
name: Mobile Tests

on: [push]

jobs:
  android:
    runs-on: ubuntu-latest
    env:
      PLATFORM: android
    steps:
      - uses: actions/checkout@v4
      - uses: android-actions/setup-android@v3
      
      - name: Start emulator
        run: |
          echo "no" | avdmanager create avd -n pixel7 \
            -k "system-images;android-34;google_apis;x86_64"
          $ANDROID_HOME/emulator/emulator \
            -avd pixel7 -no-audio -no-window \
            -gpu swiftshader_indirect &
          adb wait-for-device
          
      - run: npm ci
      - run: npm install -g appium && appium driver install uiautomator2
      - run: npx wdio --config wdio.android.conf.js

  ios:
    runs-on: macos-latest
    env:
      PLATFORM: ios
    steps:
      - uses: actions/checkout@v4
      
      - name: Boot simulator
        run: xcrun simctl boot "iPhone 15" || true
      
      - run: npm ci
      - run: npm install -g appium && appium driver install xcuitest
      - run: npx wdio --config wdio.ios.conf.js

Test Organization Patterns

tests/
├── auth/
│   ├── login.spec.js
│   ├── logout.spec.js
│   └── biometric.spec.js
├── onboarding/
│   ├── welcome.spec.js
│   └── permissions.spec.js
└── helpers/
    ├── selectors.js
    ├── platform.js
    └── gestures.js

One test file covers both platforms. Platform differences handled in helpers.

When to Have Platform-Specific Tests

Some tests should be platform-specific:

  • iOS Handoff / Continuity features
  • Android back-button behavior
  • iOS widget testing
  • Android notification channels
  • Platform-specific payment flows (Apple Pay vs Google Pay)

For these, use clearly named files:

tests/
├── ios-specific/
│   ├── faceid.spec.js
│   ├── apple-pay.spec.js
│   └── widgets.spec.js
└── android-specific/
    ├── back-button.spec.js
    └── google-pay.spec.js

Skip them by platform in CI:

# iOS job
- run: npx wdio --spec 'tests/!(android-specific)/**/*.spec.js'

# Android job  
- run: npx wdio --spec 'tests/!(ios-specific)/**/*.spec.js'

Measuring Coverage

Track which features have cross-platform test coverage:

// Generate coverage report
const features = [
  { name: "Login", ios: true, android: true },
  { name: "Registration", ios: true, android: false },  // Android test missing
  { name: "Biometric Auth", ios: true, android: true },
  { name: "Push Notifications", ios: true, android: false },
];

const missing = features.filter(f => !f.ios || !f.android);
if (missing.length > 0) {
  console.warn("Missing cross-platform coverage:", missing.map(f => f.name));
}

Performance Benchmarks by Platform

Include performance assertions in your tests to catch regressions:

it("login completes within 3 seconds", async () => {
  await $("~email-input").setValue("user@example.com");
  await $("~password-input").setValue("pass");
  
  const start = Date.now();
  await $("~sign-in-button").click();
  await $("~dashboard-screen").waitForDisplayed({ timeout: 5000 });
  const duration = Date.now() - start;
  
  const maxDuration = driver.capabilities.platformName === "iOS" ? 2000 : 3000;
  expect(duration).toBeLessThan(maxDuration);
});

The payoff of cross-platform test architecture is proportional to the size of your test suite. When you have 10 tests, two separate suites is manageable. When you have 200 tests, maintaining two parallel codebases is a full-time job. Build the abstraction layer from the start and let your test suite grow without the maintenance tax.

Read more

Start now free