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()returnsthis(fluent interface) — allows method chainingtapLogin()returnsnew HomePage(driver)— represents the navigation: after a successful login you land on the home page- Locators are
private static finalconstants 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.