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 stateThreadLocal— 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