Guided Walkthrough — POM, Tests, Cross-Platform

12 min read

This lesson walks through building the FinanceApp test suite module by module. Every piece of code here is the solution — but you will get more from this if you attempt each module yourself first and use this walkthrough to compare approaches and unblock when you're stuck.

Step 1: DriverFactory

DriverFactory is the single place where drivers are created. It reads a platform system property to decide which driver to instantiate. This keeps all Appium configuration in one file.

package com.qa.utils;
 
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.ios.options.XCUITestOptions;
 
import java.net.URL;
import java.time.Duration;
 
public class DriverFactory {
 
    private static final String APPIUM_URL = "http://127.0.0.1:4723";
 
    public static AppiumDriver createDriver() throws Exception {
        String platform = System.getProperty("platform", "android").toLowerCase();
        return "ios".equals(platform) ? createIOSDriver() : createAndroidDriver();
    }
 
    private static AndroidDriver createAndroidDriver() throws Exception {
        UiAutomator2Options options = new UiAutomator2Options()
            .setDeviceName("emulator-5554")
            .setPlatformVersion("14")
            .setApp(resourcePath("FinanceApp-debug.apk"))
            .setNewCommandTimeout(Duration.ofSeconds(60));
        return new AndroidDriver(new URL(APPIUM_URL), options);
    }
 
    private static IOSDriver createIOSDriver() throws Exception {
        XCUITestOptions options = new XCUITestOptions()
            .setDeviceName("iPhone 15 Pro")
            .setPlatformVersion("17.2")
            .setApp(resourcePath("FinanceApp.app"))
            .setNewCommandTimeout(Duration.ofSeconds(60));
        return new IOSDriver(new URL(APPIUM_URL), options);
    }
 
    private static String resourcePath(String filename) {
        return System.getProperty("user.dir") + "/src/test/resources/" + filename;
    }
}

Step 2: BaseTest

package com.qa.base;
 
import com.qa.utils.DriverFactory;
import io.appium.java_client.AppiumDriver;
import org.openqa.selenium.OutputType;
import org.testng.ITestResult;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
 
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
 
public class BaseTest {
 
    private static final ThreadLocal<AppiumDriver> driverThread = new ThreadLocal<>();
 
    protected AppiumDriver getDriver() {
        return driverThread.get();
    }
 
    @BeforeMethod
    public void setUp() throws Exception {
        AppiumDriver driver = DriverFactory.createDriver();
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
        driverThread.set(driver);
    }
 
    @AfterMethod
    public void tearDown(ITestResult result) throws IOException {
        AppiumDriver driver = driverThread.get();
        if (driver != null) {
            if (!result.isSuccess()) {
                byte[] screenshot = driver.getScreenshotAs(OutputType.BYTES);
                Path dir = Path.of("target/screenshots");
                Files.createDirectories(dir);
                Files.write(dir.resolve(result.getName() + ".png"), screenshot);
            }
            driver.quit();
            driverThread.remove();
        }
    }
}

Key decisions:

  • @BeforeMethod / @AfterMethod (not @BeforeClass) — each test method gets a fresh driver and a clean app state
  • ThreadLocal — safe for parallel execution
  • Screenshot saved as bytes (no temp file) — cleaner than OutputType.FILE

Step 3: WaitUtils

Centralise explicit waits so page classes don't each create their own WebDriverWait:

package com.qa.utils;
 
import io.appium.java_client.AppiumDriver;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
 
import java.time.Duration;
 
public class WaitUtils {
 
    private final WebDriverWait wait;
 
    public WaitUtils(AppiumDriver driver) {
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(15));
    }
 
    public WebElement waitForVisible(By locator) {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
    }
 
    public WebElement waitForClickable(By locator) {
        return wait.until(ExpectedConditions.elementToBeClickable(locator));
    }
 
    public boolean waitForText(By locator, String text) {
        return wait.until(ExpectedConditions.textToBePresentInElementLocated(locator, text));
    }
}

Step 4: LoginPage

package com.qa.pages;
 
import com.qa.utils.WaitUtils;
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.AppiumBy;
 
public class LoginPage {
 
    private final AppiumDriver driver;
    private final WaitUtils wait;
 
    private static final By EMAIL_FIELD = AppiumBy.accessibilityId("email_input");
    private static final By PASSWORD_FIELD = AppiumBy.accessibilityId("password_input");
    private static final By LOGIN_BUTTON = AppiumBy.accessibilityId("login_button");
    private static final By ERROR_LABEL = AppiumBy.accessibilityId("login_error");
 
    public LoginPage(AppiumDriver driver) {
        this.driver = driver;
        this.wait = new WaitUtils(driver);
    }
 
    public LoginPage enterEmail(String email) {
        wait.waitForVisible(EMAIL_FIELD).clear();
        driver.findElement(EMAIL_FIELD).sendKeys(email);
        return this;
    }
 
    public LoginPage enterPassword(String password) {
        driver.findElement(PASSWORD_FIELD).clear();
        driver.findElement(PASSWORD_FIELD).sendKeys(password);
        return this;
    }
 
    public DashboardPage tapLogin() {
        wait.waitForClickable(LOGIN_BUTTON).click();
        return new DashboardPage(driver);
    }
 
    public LoginPage tapLoginExpectingError() {
        wait.waitForClickable(LOGIN_BUTTON).click();
        return this;
    }
 
    public String getErrorText() {
        return wait.waitForVisible(ERROR_LABEL).getText();
    }
 
    public boolean isErrorDisplayed() {
        return !driver.findElements(ERROR_LABEL).isEmpty();
    }
}

Step 5: DashboardPage

package com.qa.pages;
 
import com.qa.utils.WaitUtils;
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.AppiumBy;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
 
import java.util.List;
 
public class DashboardPage {
 
    private final AppiumDriver driver;
    private final WaitUtils wait;
 
    private static final By BALANCE_LABEL = AppiumBy.accessibilityId("account_balance");
    private static final By TRANSACTION_LIST = AppiumBy.accessibilityId("transaction_list");
    private static final By TRANSFER_BUTTON = AppiumBy.accessibilityId("transfer_button");
    private static final By STATEMENTS_BUTTON = AppiumBy.accessibilityId("statements_button");
 
    public DashboardPage(AppiumDriver driver) {
        this.driver = driver;
        this.wait = new WaitUtils(driver);
    }
 
    public boolean isBalanceVisible() {
        return wait.waitForVisible(BALANCE_LABEL).isDisplayed();
    }
 
    public String getBalance() {
        return driver.findElement(BALANCE_LABEL).getText();
    }
 
    public List<WebElement> getTransactions() {
        return driver.findElements(By.xpath("//*[@resource-id='transaction_item']"));
    }
 
    public TransferPage tapTransfer() {
        driver.findElement(TRANSFER_BUTTON).click();
        return new TransferPage(driver);
    }
 
    public StatementsPage tapStatements() {
        driver.findElement(STATEMENTS_BUTTON).click();
        return new StatementsPage(driver);
    }
}

Step 6: StatementsPage with context switching

package com.qa.pages;
 
import io.appium.java_client.AppiumDriver;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
 
import java.time.Duration;
import java.util.List;
import java.util.Set;
 
public class StatementsPage {
 
    private final AppiumDriver driver;
 
    public StatementsPage(AppiumDriver driver) {
        this.driver = driver;
        switchToWebView();
    }
 
    private void switchToWebView() {
        new WebDriverWait(driver, Duration.ofSeconds(15))
            .until(d -> d.getContextHandles().stream().anyMatch(c -> c.startsWith("WEBVIEW")));
 
        String webCtx = driver.getContextHandles().stream()
            .filter(c -> c.startsWith("WEBVIEW"))
            .findFirst()
            .orElseThrow();
 
        driver.context(webCtx);
    }
 
    public List<WebElement> getTransactionRows() {
        new WebDriverWait(driver, Duration.ofSeconds(10))
            .until(ExpectedConditions.presenceOfElementLocated(By.cssSelector("tr.transaction")));
        return driver.findElements(By.cssSelector("tr.transaction"));
    }
 
    public void returnToNative() {
        driver.context("NATIVE_APP");
    }
}

Step 7: LoginTest

package com.qa.tests;
 
import com.qa.base.BaseTest;
import com.qa.pages.DashboardPage;
import com.qa.pages.LoginPage;
import org.testng.Assert;
import org.testng.annotations.Test;
 
public class LoginTest extends BaseTest {
 
    @Test(groups = {"smoke"})
    public void validLogin() {
        DashboardPage dashboard = new LoginPage(getDriver())
            .enterEmail("user@financeapp.com")
            .enterPassword("Password1!")
            .tapLogin();
 
        Assert.assertTrue(dashboard.isBalanceVisible(), "Balance should be visible after login");
    }
 
    @Test(groups = {"regression"})
    public void invalidPasswordShowsError() {
        LoginPage loginPage = new LoginPage(getDriver())
            .enterEmail("user@financeapp.com")
            .enterPassword("wrongpassword")
            .tapLoginExpectingError();
 
        Assert.assertTrue(loginPage.isErrorDisplayed(), "Error message should appear");
        Assert.assertEquals(loginPage.getErrorText(), "Invalid email or password");
    }
}

Step 8: testng-parallel.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="FinanceApp Parallel Regression" parallel="methods" thread-count="3">
  <test name="All Tests">
    <groups>
      <run>
        <include name="smoke"/>
        <include name="regression"/>
      </run>
    </groups>
    <classes>
      <class name="com.qa.tests.LoginTest"/>
      <class name="com.qa.tests.DashboardTest"/>
      <class name="com.qa.tests.TransferTest"/>
      <class name="com.qa.tests.StatementsTest"/>
    </classes>
  </test>
</suite>

Three threads means three simultaneous emulator sessions. For local parallel runs, start three Appium servers on ports 4723, 4724, 4725 and update DriverFactory to use a thread-local port pool. For cloud execution, three concurrent sessions is a single line change in the cloud capabilities.

Running the suite

# Smoke tests only (Android)
mvn test -Dtestng.xml=testng.xml -Dgroups=smoke
 
# Full regression in parallel (Android)
mvn test -Dtestng.xml=testng-parallel.xml
 
# Full smoke on iOS
mvn test -Dplatform=ios -Dgroups=smoke

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