Mobile Page Object Model

9 min read

Page Object Model is not optional at scale. Without it, a single UI change requires hunting through dozens of test files to update locators. With it, a locator change is a one-line edit in one class. The pattern for mobile Appium follows the same principles as web Selenium POM — but there are mobile-specific considerations around platform differences, AppiumDriver types, and the PageFactory annotation approach.

The problem without POM

A login test without POM looks like this:

// TestA
driver.findElement(AppiumBy.id("com.app:id/email")).sendKeys("user@test.com");
driver.findElement(AppiumBy.id("com.app:id/password")).sendKeys("secret");
driver.findElement(AppiumBy.id("com.app:id/login_btn")).click();
 
// TestB — same locators repeated
driver.findElement(AppiumBy.id("com.app:id/email")).sendKeys("admin@test.com");
driver.findElement(AppiumBy.id("com.app:id/password")).sendKeys("admin");
driver.findElement(AppiumBy.id("com.app:id/login_btn")).click();

The day the developer renames login_btn to sign_in_button, you update every test file that contains it. With 50 tests touching the login screen, that is 50 edits.

POM structure for mobile

The standard structure for a mobile POM project:

src/test/java/com/qa/
├── base/
│   └── BaseTest.java
├── pages/
│   ├── LoginPage.java
│   ├── HomePage.java
│   └── ProfilePage.java
└── tests/
    ├── LoginTest.java
    └── ProfileTest.java

Writing a page object

package com.qa.pages;
 
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.AppiumBy;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
 
import java.time.Duration;
 
public class LoginPage {
 
    private final AppiumDriver driver;
    private final WebDriverWait wait;
 
    // Locators as constants — the only place to update when UI changes
    private static final String EMAIL_FIELD_ID = "com.example:id/email_input";
    private static final String PASSWORD_FIELD_ID = "com.example:id/password_input";
    private static final String LOGIN_BUTTON_A11Y = "login_button";
    private static final String ERROR_MESSAGE_ID = "com.example:id/error_text";
 
    public LoginPage(AppiumDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(15));
    }
 
    public LoginPage enterEmail(String email) {
        wait.until(ExpectedConditions.visibilityOfElementLocated(
            AppiumBy.id(EMAIL_FIELD_ID)
        )).clear();
        driver.findElement(AppiumBy.id(EMAIL_FIELD_ID)).sendKeys(email);
        return this;
    }
 
    public LoginPage enterPassword(String password) {
        driver.findElement(AppiumBy.id(PASSWORD_FIELD_ID)).clear();
        driver.findElement(AppiumBy.id(PASSWORD_FIELD_ID)).sendKeys(password);
        return this;
    }
 
    public HomePage tapLogin() {
        driver.findElement(AppiumBy.accessibilityId(LOGIN_BUTTON_A11Y)).click();
        return new HomePage(driver);
    }
 
    public String getErrorMessage() {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(
            AppiumBy.id(ERROR_MESSAGE_ID)
        )).getText();
    }
 
    public boolean isErrorDisplayed() {
        return !driver.findElements(AppiumBy.id(ERROR_MESSAGE_ID)).isEmpty();
    }
}

Key design decisions:

  • enterEmail() returns this (fluent interface) — allows method chaining
  • tapLogin() returns new HomePage(driver) — represents the navigation: after a successful login you land on the home page
  • Locators are private static final constants at the top of the class — zero test code touches a locator string directly
  • Waits live inside page methods, not in test code — tests are clean

Writing a test with POM

package com.qa.tests;
 
import com.qa.base.BaseTest;
import com.qa.pages.LoginPage;
import com.qa.pages.HomePage;
import org.testng.Assert;
import org.testng.annotations.Test;
 
public class LoginTest extends BaseTest {
 
    @Test
    public void successfulLogin() {
        LoginPage loginPage = new LoginPage(driver);
        HomePage homePage = loginPage
            .enterEmail("user@example.com")
            .enterPassword("password123")
            .tapLogin();
 
        Assert.assertTrue(homePage.isWelcomeBannerVisible());
    }
 
    @Test
    public void loginWithWrongPassword() {
        LoginPage loginPage = new LoginPage(driver);
        loginPage
            .enterEmail("user@example.com")
            .enterPassword("wrongpassword")
            .tapLogin();
 
        Assert.assertEquals(loginPage.getErrorMessage(), "Invalid email or password");
    }
}

The test reads like a user story. No locators, no findElement, no waits — all of that is inside the page class where it belongs.

Handling cross-platform differences in page objects

When the Android and iOS locators differ, there are two clean approaches:

Approach 1 — Conditional logic inside the page class:

private By getLoginButtonLocator() {
    if (driver instanceof AndroidDriver) {
        return AppiumBy.id("com.example:id/login_btn");
    } else {
        return AppiumBy.accessibilityId("LoginButton");
    }
}

Approach 2 — Abstract base page with platform subclasses:

public abstract class LoginPage {
    protected final AppiumDriver driver;
    public LoginPage(AppiumDriver driver) { this.driver = driver; }
 
    public abstract LoginPage enterEmail(String email);
    public abstract LoginPage enterPassword(String password);
    public abstract HomePage tapLogin();
}
 
public class AndroidLoginPage extends LoginPage { /* Android locators */ }
public class IOSLoginPage extends LoginPage { /* iOS locators */ }

The factory pattern selects the right implementation based on the driver type at runtime. This keeps locators cleanly separated and avoids conditional chains in every method.

PageFactory and @FindBy (optional)

Appium supports Selenium's PageFactory with @FindBy annotations:

import io.appium.java_client.pagefactory.AppiumFieldDecorator;
import io.appium.java_client.pagefactory.AndroidFindBy;
import io.appium.java_client.pagefactory.iOSXCUITFindBy;
import org.openqa.selenium.support.PageFactory;
 
public class LoginPage {
 
    @AndroidFindBy(id = "com.example:id/email_input")
    @iOSXCUITFindBy(accessibility = "email_input")
    private WebElement emailField;
 
    public LoginPage(AppiumDriver driver) {
        PageFactory.initElements(new AppiumFieldDecorator(driver), this);
    }
}

AppiumFieldDecorator handles the dual @AndroidFindBy / @iOSXCUITFindBy annotations. The element is lazy-loaded — it is not found until you first access the field, which avoids StaleElementReferenceException after navigations.

Both approaches (manual findElement and PageFactory) work. Manual locators give you more control over caching and explicit waits. PageFactory reduces boilerplate but can be harder to debug when elements go stale.

// tip to track lessons you complete and pick up where you left off across devices.