Skip to Content
Mobile Automation
Sign in
  • Home
  • Start Here
  • About
  • Blog
  • Tags
  • Contact
  • X

One Test, Two Platforms: Cross-Platform Mobile Testing with Page Object Model

Run the same test on Android and iOS with one change.

Featured Post For Members Framework June 8, 2026
One Test, Two Platforms: Cross-Platform Mobile Testing with Page Object Model
On this page
Unlock full content

The framework runs on Android — and only Android. Every screen object uses @AndroidFindBy, which tells Appium's PageFactory to look for Android locators and ignore everything else. Try to run against an iOS simulator and AppiumFieldDecorator finds no recognized annotations, every field stays null, and your first test fails the moment it touches an element.

AppiumFieldDecorator supports two locator annotations on the same field — one for Android, one for iOS — and picks the right one at runtime based on the driver type. You keep LoginScreen as a single class. You keep the step definitions exactly as they are. You change two lines in application.properties and run on iOS.

Add one iOS annotation per field. That's the full scope of the change.

💡 This is Article 5 of the Framework Series. It picks up directly from BDD and Cucumber with Appium: A Practical Integration Guide. The DriverManager, BasePage, Cucumber wiring, and step definitions from Articles 3 and 4 are all in place — this article extends the screen objects, nothing else.

How Dual Annotations Work

When AppiumFieldDecorator initializes a screen object, it reads the annotations on each field and decides which locator to use based on the driver type in the current session:

  • If the session is an AndroidDriver, it reads @AndroidFindBy and ignores @iOSXCUITFindBy.
  • If the session is an IOSDriver, it reads @iOSXCUITFindBy and ignores @AndroidFindBy.

This means you can declare both on the same field:

@AndroidFindBy(accessibility = "input-email")
@iOSXCUITFindBy(accessibility = "input-email")
private WebElement emailField;

At runtime, only the annotation that matches the active driver is used. The other is silently skipped. One class, both platforms.

💡 This is DRY from the framework blueprint: one screen object per screen, one place to update when the UI changes. There's no Android copy and iOS copy to keep in sync — the two locators live side by side in a single class.

What You're Building

This article makes two changes to the framework:

  1. Update HomeScreen — add @iOSXCUITFindBy alongside each existing @AndroidFindBy
  2. Update LoginScreen — same treatment

The step definitions, the feature file, the Cucumber wiring, the DriverManager — none of it changes. DriverManager already creates an IOSDriver when test.platform=ios; AppiumFieldDecorator handles the rest automatically.

Step 1: Update HomeScreen

Open team-tests/src/main/java/com/mobileframework/screens/HomeScreen.java and add @iOSXCUITFindBy alongside each existing Android annotation:

package com.mobileframework.screens;

import com.mobileframework.driver.DriverManager;
import com.mobileframework.pages.BasePage;
import io.appium.java_client.pagefactory.AndroidFindBy;
import io.appium.java_client.pagefactory.iOSXCUITFindBy;
import org.openqa.selenium.WebElement;
import org.springframework.stereotype.Component;

@Component
public class HomeScreen extends BasePage {

    @AndroidFindBy(accessibility = "Home-screen")
    @iOSXCUITFindBy(accessibility = "Home")
    private WebElement screenContainer;

    @AndroidFindBy(accessibility = "Login")
    @iOSXCUITFindBy(accessibility = "Login")
    private WebElement loginMenuItem;

    public HomeScreen(DriverManager driverManager) {
        super(driverManager);
    }

    public boolean isLoaded() {
        return screenContainer.isDisplayed();
    }

    public void tapLogin() {
        loginMenuItem.click();
    }

}

The only changes are the two new import lines and the @iOSXCUITFindBy annotations. The class name, constructor, and methods are untouched.

Step 2: Update LoginScreen

Open team-tests/src/main/java/com/mobileframework/screens/LoginScreen.java and add the iOS annotations:

package com.mobileframework.screens;

import com.mobileframework.driver.DriverManager;
import com.mobileframework.pages.BasePage;
import io.appium.java_client.pagefactory.AndroidFindBy;
import io.appium.java_client.pagefactory.iOSXCUITFindBy;
import org.openqa.selenium.WebElement;
import org.springframework.stereotype.Component;

@Component
public class LoginScreen extends BasePage {

    @AndroidFindBy(accessibility = "input-email")
    @iOSXCUITFindBy(accessibility = "input-email")
    private WebElement emailField;

    @AndroidFindBy(accessibility = "input-password")
    @iOSXCUITFindBy(accessibility = "input-password")
    private WebElement passwordField;

    @AndroidFindBy(accessibility = "button-LOGIN")
    @iOSXCUITFindBy(accessibility = "button-LOGIN")
    private WebElement loginButton;

    @AndroidFindBy(id = "android:id/message")
    @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeAlert' AND name == 'Success'")
    private WebElement alertMessage;

    public LoginScreen(DriverManager driverManager) {
        super(driverManager);
    }

    public boolean isLoaded() {
        return emailField.isDisplayed();
    }

    public void login(String email, String password) {
        emailField.sendKeys(email);
        passwordField.sendKeys(password);
        loginButton.click();
    }

    public boolean isLoginSuccessful() {
        return alertMessage.isDisplayed();
    }

}

Most fields use the same accessibility identifier on both platforms — "input-email", "input-password", "button-LOGIN". The alertMessage field is the exception: Android uses a system alert dialog located by resource ID (android:id/message), while iOS uses a native XCUIElementTypeAlert located by NSPredicate (type == 'XCUIElementTypeAlert' AND name == 'Success'). The two annotations on that field show exactly where the platforms diverge.

Step 3: Run on Android

No step definition or feature file changes are needed. Make sure application.properties targets Android:

test.platform=android
device.udid=emulator-5554
app.path=/path/to/android.wdio.native.app.v2.0.0.apk
app.package=com.wdiodemoapp
app.activity=com.wdiodemoapp.MainActivity

Then run from the project root:

mvn clean test

DriverManager creates an AndroidDriver. AppiumFieldDecorator reads @AndroidFindBy on every field and skips @iOSXCUITFindBy. The scenario runs identically to Article 4.

0:00
/0:18

Step 4: Run on iOS

Update application.properties to target iOS:

test.platform=ios
device.udid=<your-simulator-udid>
app.path=/path/to/iOS.Simulator.NativeDemoApp.v2.0.0.app

To find your simulator UDID, run xcrun simctl list devices in a terminal. Remove app.package and app.activity — those properties have empty string defaults and are silently ignored when the iOS branch in DriverManager runs.

💡 Running on a real device instead of a simulator? Use the device UDID from idevice_id -l (requires libimobiledevice) or find it in Xcode under Window > Devices and Simulators. Replace app.path with the path to your .ipa file — real devices use .ipa, simulators use the .app bundle.
💡 Make sure your simulator is booted before running. Run xcrun simctl boot <your-simulator-udid> then open -a Simulator if it isn't already running — Appium will fail to create the session against a shutdown simulator.

Then:

mvn clean test

DriverManager creates an IOSDriver. AppiumFieldDecorator reads @iOSXCUITFindBy on every field and skips @AndroidFindBy. The same scenario runs, the same step definitions execute, the feature file hasn't changed.

0:00
/0:17

💡 Switching platforms from the command line. Instead of editing application.properties, pass properties as Maven system properties: mvn clean test -Dtest.platform=ios -Ddevice.udid=<ios-udid> -Dapp.path=<ios-app-path>. You still need to supply the correct device.udid and app.path for the target platform — test.platform alone isn't enough. The key insight is that application.properties keeps your Android values as the default; the CLI properties override only what you pass, so you're not replacing the file — you're patching it at runtime. This is useful when running both platforms in CI without maintaining separate properties files. Article 6 (Configuration Management) takes this further with Spring profiles and externalized configuration.

🚨 Common failure points at this step:

  • Elements not found on iOS — locator mismatch. Connect Appium Inspector to the iOS simulator and confirm the identifiers match what's in your version of the app. The alertMessage locator uses iOSNsPredicate = "type == 'XCUIElementTypeAlert' AND name == 'Success'" — if your build shows a different alert title, update the name value in the predicate accordingly.
  • DriverManager creates an IOSDriver but the XCUITest session fails — the most common cause is a mismatch between device.udid and app.path. Make sure both point to the same target: simulator UDID with a .app bundle, or real device UDID with an .ipa. See the setup steps above for how to find each.
  • Both @AndroidFindBy and @iOSXCUITFindBy seem to be ignored — AppiumFieldDecorator isn't initialized. This means BasePage's constructor didn't run. Check that your screen object calls super(driverManager) and that DriverManager is injected correctly.

Understanding What You've Built

The change across this article was minimal by design:

Component Change Why
HomeScreen Added @iOSXCUITFindBy to each field AppiumFieldDecorator uses the annotation that matches the active driver
LoginScreen Added @iOSXCUITFindBy to each field Same — alertMessage shows where platforms genuinely diverge
LoginScreenSteps None Step definitions call screen object methods; they don't touch locators
login.feature None The scenario describes behavior, not implementation
application.properties test.platform, device.udid, app.path Controls which driver DriverManager creates — the only runtime switch
DriverManager None Already handles both platforms since Article 3
💡 The dual-annotation approach works well as long as the two platforms share the same interaction logic and only the locators differ. When a screen needs genuinely different method behavior per platform — different gestures, elements that exist on one platform but not the other — the right move is to extract a shared interface and write separate implementations. That design is covered later in the series when the framework is tested against more complex screens.

What's Next?

The next article — Configuration Management in Depth: Environments, Device Profiles, and Test Profiles — takes application.properties and replaces it with a proper configuration system: Spring profiles for environments, device profiles for different hardware, and test profiles for different run configurations. That one-line test.platform switch you're making manually today becomes something you can drive entirely from your CI pipeline without editing a single file.

Each article picks up exactly where the last one left off. If mvn clean test passed on both Android and iOS, the cross-platform foundation is in place.

Download the Code

The complete, runnable workspace for this article is available as a free download — everything built across Articles 2, 3, 4, and 5, wired together and ready to open in your IDE.

What's inside:

  • The full Maven multi-module project — root POM, core module, team-tests module
  • DriverManager and BasePage from Article 3
  • Cucumber wiring from Article 4 — RunCucumberTest, CucumberSpringConfiguration, login.feature, junit-platform.properties
  • HomeScreen and LoginScreen — both updated with dual @AndroidFindBy / @iOSXCUITFindBy annotations
  • LoginScreenSteps — unchanged from Article 4
  • application.properties template — update test.platform, device.udid, and app.path before running

💡 Before running, open application.properties and set test.platform, device.udid, and app.path to match your machine and the platform you want to test first. Then run mvn clean test from the project root — Steps 3 and 4 above cover the full prerequisite checklist.

✅ Subscribe below to download the workspace .zip — free, just your email, and you'll get every new Framework Series article as it's published.

This post is for subscribers only

Become a member now and have access to all posts, enjoy exclusive content, and stay updated with constant updates.

Become a member

Already have an account? Sign in

  • Share on X
  • Share on Facebook
  • Share on LinkedIn
  • Share on Pinterest
  • Email

Written by

Mayvin Ramasawmy

Software engineer with 18 years of experience in development and test automation. From startups to enterprise — I build automation frameworks and share practical mobile testing knowledge at mobile-automation.io.

Montreal, Canada

Related Posts

BDD and Cucumber with Appium: Plain-English Tests for Your Framework
Featured Post For Members

BDD and Cucumber with Appium: Plain-English Tests for Your Framework

Framework June 1, 2026
Driver Setup and Screen Objects: Building the Working Core of Your Mobile Framework
Featured Post For Members

Driver Setup and Screen Objects: Building the Working Core of Your Mobile Framework

Framework May 26, 2026
How to Structure a Mobile Test Automation Framework That Scales Across Teams
Featured Post

How to Structure a Mobile Test Automation Framework That Scales Across Teams

Framework May 19, 2026

Join the Mobile Automation Community

Receive new Appium and Maestro tutorials, framework design tips, and mobile testing best practices.

Please check your inbox and click the confirmation link.
Subscribe 1 Subscribe 2 Subscribe 3 Subscribe 1 Clone Subscribe 2 Clone Subscribe 3 Clone Subscribe 1 Clone
Subscribe 4 Subscribe 5 Subscribe 6 Subscribe 4 Clone Subscribe 5 Clone Subscribe 6 Clone Subscribe 4 Clone

Tags

Appium Best Practices Java Framework
© 2026 Mobile Automation
Mobile Automation
  • Home
  • Start Here
  • About
  • Blog
  • Tags
  • Contact
  • X
Sign in