What is Appium with Java?
Appium is the most widely adopted open-source framework for automating mobile applications on Android and iOS, and Java has become the dominant language choice for enterprise Appium frameworks. The combination of Java's static type safety, Maven's mature dependency management ecosystem, TestNG's powerful parallel execution and reporting capabilities, and the availability of libraries like Extent Reports makes Java the go-to stack for large QA teams in financial services, telecommunications, and enterprise software.
Java's static typing catches errors at compile time rather than at runtime, which is a significant advantage in mobile testing where test failures on devices can be expensive and slow to diagnose. When you misspell a method name or pass the wrong type to a capability setting, the Java compiler tells you immediately — before you ever run a single test. In contrast, Python's dynamic typing means these errors only surface when the affected line of code executes, which may happen deep into a test suite run.
TestNG brings a level of test orchestration that JUnit cannot match out of the box. Its native support for parallel execution across threads, methods, classes, and suites makes it ideal for mobile testing where you want to run multiple test scenarios simultaneously across different devices or API levels. TestNG's data providers, group filtering, and dependency injection also pair naturally with the kind of complex test matrices that enterprise mobile testing requires — testing across ten device configurations, three app versions, and two environments simultaneously.
The Appium Java Client, currently at version 9.x, is built on top of Selenium 4's WebDriver implementation and fully embraces the W3C WebDriver protocol. This means that capabilities, gestures, and element interactions all follow standardized W3C conventions rather than the JSONWire Protocol used in older Appium versions. If you are coming from Appium 1.x with Desired Capabilities, the migration to AppiumOptions and the Java client 9.x is well worth the effort — the new API is cleaner, more type-safe, and better aligned with how modern WebDriver clients work across both web and mobile automation.
Appium 2.x introduced a modular architecture where drivers are no longer bundled with the server. Instead of installing one monolithic Appium package that includes Android, iOS, and other drivers, you now install the Appium server separately and then install only the drivers you need. This means appium driver install uiautomator2 for Android and appium driver install xcuitest for iOS. The result is a leaner server with faster startup times and independently versioned drivers that can be updated without upgrading the server itself.
At Viasat Europe, I use a Java-based Appium framework to automate testing of inflight entertainment systems — Android-based applications running on seat-back display units inside commercial aircraft. These are real Android devices in a constrained environment, and the Java stack gives us the enterprise-grade reliability and reporting we need to run nightly regression suites and produce reports that product managers and airline customers can actually read and understand.
Appium Architecture
Understanding Appium's client-server architecture is essential before writing your first test. When you execute an Appium test in Java, a specific chain of communication happens across multiple layers, and understanding what each layer does helps you diagnose failures quickly when something goes wrong.
The Appium Java Client is the library you add to your Maven project via pom.xml. It provides the AndroidDriver, IOSDriver, AppiumBy, and gesture classes. When your test calls driver.findElement() or driver.perform(), the Java Client translates that call into an HTTP request following the W3C WebDriver protocol. These HTTP requests are sent to the Appium Server, which by default listens on port 4723.
The Appium Server is a Node.js application that acts as the central broker. It receives W3C WebDriver HTTP commands from the Java Client, interprets them, and forwards them to the appropriate driver — UIAutomator2 for Android or XCUITest for iOS. The server also manages sessions, meaning it tracks which driver instance belongs to which set of capabilities and routes commands accordingly. This is why you can run multiple sessions on the same Appium server (parallel testing) as long as the server is configured to accept concurrent connections.
UIAutomator2 is Google's official automation framework for Android. It runs as a small HTTP server directly on the Android device or emulator — it is literally installed as two APKs when a test session begins. When the Appium Server forwards a command like "find element with accessibility id open menu," UIAutomator2 communicates with the Android accessibility service to locate that element in the view hierarchy and returns its reference. This is what makes Appium black-box automation — it communicates through the device's native accessibility APIs, exactly like a screen reader would, and requires no access to the application's source code.
The architecture diagram above shows the five-layer communication chain. Each layer is responsible for a specific translation: the Java Client translates your test code to HTTP, the Appium Server routes HTTP commands to the correct driver, and UIAutomator2 translates WebDriver commands to native Android accessibility API calls. When a test fails with "element not found," the failure could originate at any layer — a wrong locator (test script level), a session timeout (server level), or a UIAutomator2 crash (driver level). Understanding the architecture tells you exactly where to look first.
Maven Project Setup
Maven is the standard build tool for Java-based Appium frameworks in enterprise environments. It manages dependencies, handles the build lifecycle, and integrates with CI systems like Jenkins and GitHub Actions. Below is a complete, production-ready pom.xml that includes Appium Java Client 9.x, Selenium 4, TestNG 7, Extent Reports 5, and WebDriverManager.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.qachronicles</groupId>
<artifactId>appium-java-framework</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<appium.version>9.2.2</appium.version>
<selenium.version>4.20.0</selenium.version>
<testng.version>7.10.2</testng.version>
</properties>
<dependencies>
<dependency>
<groupId>io.appium</groupId>
<artifactId>java-client</artifactId>
<version>${appium.version}</version>
</dependency>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>${selenium.version}</version>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>${testng.version}</version>
</dependency>
<dependency>
<groupId>com.aventstack</groupId>
<artifactId>extentreports</artifactId>
<version>5.1.1</version>
</dependency>
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.8.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<suiteXmlFiles>
<suiteXmlFile>testng.xml</suiteXmlFile>
</suiteXmlFiles>
</configuration>
</plugin>
</plugins>
</build>
</project>
Let me walk through the key dependency choices. The Appium Java Client 9.x requires Selenium 4.x because it is built on top of the Selenium 4 WebDriver implementation. Do not mix Selenium 3 with Appium Java Client 9 — they are incompatible. The TestNG version 7.10.2 is the current stable release with the best parallel execution support and the cleanest integration with Maven Surefire. The Extent Reports version 5.1.1 introduced the SparkReporter which produces the dark-theme HTML reports that look great in CI artifact viewers. WebDriverManager handles ChromeDriver download for hybrid apps automatically — you should include it even if you only test native apps, because you may encounter WebViews later.
The maven-surefire-plugin 3.2.5 configuration points to testng.xml, which is the TestNG suite descriptor file that controls which tests run, in what order, and with what degree of parallelism. This configuration means that running mvn test from the command line will automatically pick up your TestNG suite — exactly what you want in a CI pipeline.
AppiumOptions & Capabilities
In Appium Java Client 9.x, the old DesiredCapabilities approach has been replaced with typed Options classes. For Android, you use UiAutomator2Options. For iOS, you use XCUITestOptions. These classes provide type-safe setter methods that catch capability name typos at compile time rather than at session creation time. This is one of the most significant improvements in the modern Appium Java Client.
import io.appium.java_client.android.options.UiAutomator2Options;
import io.appium.java_client.ios.options.XCUITestOptions;
// Android options
UiAutomator2Options options = new UiAutomator2Options();
options.setPlatformName("Android");
options.setDeviceName("emulator-5554");
options.setAppPackage("com.saucelabs.mydemoapp.android");
options.setAppActivity(".MainActivity");
options.setAutomationName("UIAutomator2");
options.setNoReset(true);
options.setAutoGrantPermissions(true);
options.setNewCommandTimeout(Duration.ofSeconds(300));
// iOS options
XCUITestOptions iosOptions = new XCUITestOptions();
iosOptions.setPlatformName("iOS");
iosOptions.setDeviceName("iPhone 15");
iosOptions.setBundleId("com.example.myapp");
iosOptions.setAutomationName("XCUITest");
The setNoReset(true) capability tells Appium not to uninstall and reinstall the app between sessions. This dramatically speeds up test execution because app installation on Android can take 10-30 seconds depending on the APK size. Use noReset=true when your tests are stateless or when you manage app state manually in your BeforeMethod setup. Use fullReset=true when you need a completely clean device state, such as for first-launch experience tests.
setAutoGrantPermissions(true) is essential for any app that requests runtime permissions (camera, location, storage). Without this, your test will pause waiting for the user to tap "Allow" on a permission dialog, and it will never come — causing a timeout. This capability makes UIAutomator2 automatically grant all permissions during app launch.
setNewCommandTimeout(Duration.ofSeconds(300)) controls how long the Appium server waits before terminating an idle session. The default is 60 seconds, which is often too short for tests with long operations like file uploads or video streaming scenarios. Setting it to 300 seconds gives your tests plenty of headroom without leaving zombie sessions open indefinitely.
Your First Android Test
The test below uses the Sauce Labs My Demo App, a freely available Android APK designed specifically for automation practice. It demonstrates the complete lifecycle of an Appium test: setup in @BeforeMethod, the actual test steps with explicit waits, assertion with TestNG Assert, and cleanup in @AfterMethod.
package tests;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import io.appium.java_client.AppiumBy;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.testng.Assert;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import java.net.URL;
import java.time.Duration;
public class AndroidLoginTest {
private AndroidDriver driver;
private WebDriverWait wait;
@BeforeMethod
public void setUp() throws Exception {
UiAutomator2Options options = new UiAutomator2Options();
options.setPlatformName("Android");
options.setDeviceName("emulator-5554");
options.setAppPackage("com.saucelabs.mydemoapp.android");
options.setAppActivity(".MainActivity");
options.setNoReset(true);
options.setAutoGrantPermissions(true);
driver = new AndroidDriver(new URL("http://127.0.0.1:4723"), options);
wait = new WebDriverWait(driver, Duration.ofSeconds(15));
}
@Test
public void testSuccessfulLogin() {
// Open menu
wait.until(ExpectedConditions.elementToBeClickable(
AppiumBy.accessibilityId("open menu"))).click();
// Navigate to Login
wait.until(ExpectedConditions.elementToBeClickable(
AppiumBy.xpath("//android.widget.TextView[@text='Log In']"))).click();
// Enter credentials
driver.findElement(AppiumBy.accessibilityId("Username input field"))
.sendKeys("bod@example.com");
driver.findElement(AppiumBy.accessibilityId("Password input field"))
.sendKeys("10203040");
driver.findElement(AppiumBy.accessibilityId("Login button")).click();
// Assert welcome
boolean isWelcomeVisible = wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.xpath("//android.widget.TextView[@text='Bob']"))).isDisplayed();
Assert.assertTrue(isWelcomeVisible, "Login failed — welcome message not shown");
}
@AfterMethod
public void tearDown() {
if (driver != null) driver.quit();
}
}
Notice that every element interaction is preceded by an explicit wait using WebDriverWait. This is non-negotiable in mobile automation. Mobile apps are inherently slower than desktop browser applications — transitions, animations, and network calls all create timing gaps where elements exist in the view hierarchy but are not yet interactive. The ExpectedConditions.elementToBeClickable() condition waits until the element is both visible and enabled before attempting to click it, which eliminates the "element not clickable at point" errors that plague test suites that rely on sleep statements.
The null check in tearDown() is important: if setUp() throws an exception (e.g., Appium server not running, wrong capabilities), the driver will be null, and calling driver.quit() unconditionally would cause a NullPointerException that masks the actual setup error. Always guard your teardown with a null check or a try-catch block.
Mobile Locator Strategies
Choosing the right locator strategy is one of the most important decisions in mobile test automation. The wrong strategy leads to brittle tests that break every time the UI is updated. The right strategy produces tests that survive app redesigns and localization changes. Here is a comprehensive overview of all available strategies in Appium Java Client 9.x.
| Strategy | AppiumBy Method | Best For | Stability |
|---|---|---|---|
| Accessibility ID | AppiumBy.accessibilityId() | Elements with content-desc set by developers | Excellent |
| Resource ID | AppiumBy.id() | Elements with android:id attribute | Good |
| XPath | AppiumBy.xpath() | Complex hierarchical queries, text matching | Fair — avoid where possible |
| Class Name | AppiumBy.className() | Selecting by widget type (rare) | Poor — too generic |
| UIAutomator2 Selector | AppiumBy.androidUIAutomator() | Scroll-to-element, complex Android selectors | Good for Android-only |
| iOS Predicate String | AppiumBy.iOSNsPredicateString() | iOS attribute-based element selection | Good for iOS-only |
| iOS Class Chain | AppiumBy.iOSClassChain() | iOS hierarchy traversal | Good for iOS-only |
The UIAutomator2 selector is particularly powerful for scrolling scenarios. If you need to scroll down a list until a specific element comes into view, the UiScrollable API does this in a single command without requiring you to calculate scroll coordinates manually:
driver.findElement(AppiumBy.androidUIAutomator(
"new UiScrollable(new UiSelector().scrollable(true))" +
".scrollIntoView(new UiSelector().text(\"Settings\"))"));
This is significantly more reliable than performing a gesture-based swipe and then checking if the element appeared, because it handles variable list lengths automatically. Whether "Settings" is 2 items down or 20 items down, the UIScrollable will scroll until it finds it or exhausts the list. I use this pattern extensively when testing settings screens and long form lists in the Viasat IFE application.
Avoid XPath wherever possible for performance reasons. On a real Android device, resolving an XPath expression requires traversing the full accessibility tree, which can take hundreds of milliseconds per element. Multiply that across a suite with 200 test steps and you are adding minutes to your run time. When you do use XPath, prefer attribute-based selectors like [@text='value'] or [@content-desc='value'] over position-based selectors like [2] which break when the UI layout changes.
W3C Actions & Gestures
Appium 2.x dropped the legacy TouchAction and MultiTouchAction APIs in favour of the W3C Actions API, which is the same gesture API used in Selenium 4 for web automation. The W3C Actions API models touch as a "pointer" input sequence, which gives you precise control over timing, coordinates, and multi-touch interactions.
import org.openqa.selenium.interactions.PointerInput;
import org.openqa.selenium.interactions.Sequence;
import java.util.Arrays;
// Swipe up
public void swipeUp(AndroidDriver driver) {
Dimension size = driver.manage().window().getSize();
int startX = size.width / 2;
int startY = (int)(size.height * 0.8);
int endY = (int)(size.height * 0.2);
PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
Sequence swipe = new Sequence(finger, 1)
.addAction(finger.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), startX, startY))
.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()))
.addAction(finger.createPointerMove(Duration.ofMillis(600), PointerInput.Origin.viewport(), startX, endY))
.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
driver.perform(Arrays.asList(swipe));
}
// Long press
public void longPress(AndroidDriver driver, WebElement element) {
Point location = element.getLocation();
Dimension size = element.getSize();
int centerX = location.getX() + size.width / 2;
int centerY = location.getY() + size.height / 2;
PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
Sequence longPress = new Sequence(finger, 1)
.addAction(finger.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), centerX, centerY))
.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()))
.addAction(finger.createPointerMove(Duration.ofMillis(1500), PointerInput.Origin.viewport(), centerX, centerY))
.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
driver.perform(Arrays.asList(longPress));
}
The swipe duration parameter in createPointerMove controls how fast the swipe happens. A duration of 600ms produces a moderate swipe speed that reliably triggers scroll behaviour in most Android apps. Setting it too low (under 200ms) can cause the gesture to be interpreted as a tap rather than a swipe. Setting it too high (over 1500ms) produces a very slow drag that some apps do not recognize as a scroll gesture. If your swipes are not scrolling the content, experiment with the duration value first before changing the coordinates.
For long press, the key is holding the pointer down for at least 1000ms at the same location. The example above holds for 1500ms (1.5 seconds), which is reliably recognized by Android's long-press context menu system across different device manufacturers. Calculate the element center using element.getLocation() and element.getSize() rather than hardcoding pixel coordinates — this ensures the gesture works correctly across different screen sizes and densities.
Page Object for Mobile
The Page Object Model (POM) is just as important in mobile automation as it is in web automation. In POM, each screen of the application is represented by a Java class that encapsulates the locators and interactions for that screen. Your test classes then call methods on these page objects rather than directly using driver commands. This separation means that when the UI changes, you update the locator in one place rather than hunting through every test that touches that element.
package pages;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.AppiumBy;
import org.openqa.selenium.By;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
public class LoginPage {
private final AndroidDriver driver;
private final WebDriverWait wait;
private static final By MENU_BUTTON = AppiumBy.accessibilityId("open menu");
private static final By LOGIN_MENU_ITEM = AppiumBy.xpath("//android.widget.TextView[@text='Log In']");
private static final By USERNAME_FIELD = AppiumBy.accessibilityId("Username input field");
private static final By PASSWORD_FIELD = AppiumBy.accessibilityId("Password input field");
private static final By LOGIN_BUTTON = AppiumBy.accessibilityId("Login button");
public LoginPage(AndroidDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(15));
}
public void navigate() {
wait.until(ExpectedConditions.elementToBeClickable(MENU_BUTTON)).click();
wait.until(ExpectedConditions.elementToBeClickable(LOGIN_MENU_ITEM)).click();
}
public void login(String username, String password) {
driver.findElement(USERNAME_FIELD).sendKeys(username);
driver.findElement(PASSWORD_FIELD).sendKeys(password);
driver.findElement(LOGIN_BUTTON).click();
}
}
Declaring locators as private static final By constants at the top of the class serves multiple purposes. Making them static means they are initialized once at class loading time, not on every page object instantiation. Making them final prevents accidental reassignment. Declaring them at the top of the class as constants makes them easy to update when locators change — you know exactly where to look. This is a pattern I apply consistently across all mobile page objects in the Viasat framework and one I strongly recommend over inline locator strings scattered throughout method bodies.
Notice that the page object injects the WebDriverWait in its own constructor rather than relying on the test class to pass a wait instance. This ensures that every method in the page object already has access to a configured wait with the correct timeout, and tests do not need to manage wait instances separately. The driver is passed into the constructor rather than stored as a static field — this is critical for parallel test execution where different threads need different driver instances.
At Viasat, I use a Java-based Appium framework to test inflight entertainment on Android-based seat-back displays. One challenge specific to embedded devices is that the accessibility tree can be deep and slow to traverse — switching from XPath to UIAutomator2 UiSelector reduced our element-lookup time by 60% on average. The Page Object pattern also proved critical when the IFE UI was redesigned mid-project — we updated 8 page classes and all 200+ tests continued to work without modification.
Extent Reports Integration
Extent Reports 5 produces beautiful, interactive HTML reports with test status, logs, screenshots, and system information. The SparkReporter introduced in version 5 supports dark and light themes and generates a single self-contained HTML file that can be shared as a CI artifact, emailed to stakeholders, or published to GitHub Pages. The integration pattern below uses TestNG's @BeforeSuite and @AfterSuite annotations with ThreadLocal for thread-safe parallel reporting.
package utils;
import com.aventstack.extentreports.ExtentReports;
import com.aventstack.extentreports.ExtentTest;
import com.aventstack.extentreports.reporter.ExtentSparkReporter;
import com.aventstack.extentreports.reporter.configuration.Theme;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.testng.ITestResult;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.AfterSuite;
import org.testng.annotations.BeforeSuite;
public class BaseTest {
protected static ExtentReports extent;
protected static ThreadLocal<ExtentTest> test = new ThreadLocal<>();
protected AndroidDriver driver;
@BeforeSuite
public void setUpReport() {
ExtentSparkReporter spark = new ExtentSparkReporter("reports/AppiumReport.html");
spark.config().setTheme(Theme.DARK);
spark.config().setDocumentTitle("Appium Test Report");
spark.config().setReportName("Android Regression Suite");
extent = new ExtentReports();
extent.attachReporter(spark);
extent.setSystemInfo("Platform", "Android");
extent.setSystemInfo("Tester", "Honnesh Muppala");
}
@AfterMethod
public void recordResult(ITestResult result) {
if (result.getStatus() == ITestResult.FAILURE) {
test.get().fail(result.getThrowable());
try {
String base64 = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BASE64);
test.get().addScreenCaptureFromBase64String(base64, "Failure Screenshot");
} catch (Exception ignored) {}
} else if (result.getStatus() == ITestResult.SUCCESS) {
test.get().pass("Test passed");
}
}
@AfterSuite
public void tearDownReport() {
extent.flush();
}
}
The ThreadLocal<ExtentTest> is essential for parallel test reporting. When TestNG runs tests in parallel with multiple threads, each thread needs its own ExtentTest instance to log to — if you used a single shared ExtentTest field, log entries from different threads would get mixed together and produce a garbled, unreadable report. ThreadLocal guarantees that each thread's test.get() call returns only that thread's own ExtentTest instance.
The screenshot-on-failure block deserves special attention. Calling ((TakesScreenshot) driver).getScreenshotAs(OutputType.BASE64) captures the current device screen as a Base64-encoded string, which Extent Reports can embed directly into the HTML report without needing a separate image file. The try-catch block is there because the screenshot capture can fail if the driver session was already terminated (which can happen if the test caused an app crash that killed the session). Wrapping it in try-catch ensures that a screenshot capture failure does not mask the original test failure.
Parallel Execution with TestNG
Parallel test execution is one of TestNG's strongest features and one of the primary reasons Java is preferred over Python for enterprise Appium frameworks. Running three tests simultaneously instead of sequentially can reduce a 30-minute test suite to 10 minutes — a significant productivity gain in a CI pipeline where fast feedback is critical.
<!-- testng.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="Appium Suite" parallel="methods" thread-count="3" verbose="2">
<test name="Android Tests">
<classes>
<class name="tests.LoginTest"/>
<class name="tests.CheckoutTest"/>
<class name="tests.ProfileTest"/>
</classes>
</test>
</suite>
The parallel="methods" attribute instructs TestNG to run individual test methods in separate threads. With thread-count="3", up to three test methods can execute simultaneously. Each thread requires its own AndroidDriver instance connected to its own device or emulator — you cannot share a driver across threads, because driver operations are not thread-safe.
The correct pattern for parallel driver management is to store the driver in a ThreadLocal variable:
public class DriverManager {
private static final ThreadLocal<AndroidDriver> driverThread = new ThreadLocal<>();
public static void setDriver(AndroidDriver driver) {
driverThread.set(driver);
}
public static AndroidDriver getDriver() {
return driverThread.get();
}
public static void removeDriver() {
driverThread.remove();
}
}
// In BaseTest @BeforeMethod:
@BeforeMethod
public void setUp() throws Exception {
UiAutomator2Options options = new UiAutomator2Options();
// ... configure options ...
AndroidDriver driver = new AndroidDriver(new URL("http://127.0.0.1:4723"), options);
DriverManager.setDriver(driver);
}
// In BaseTest @AfterMethod:
@AfterMethod
public void tearDown() {
if (DriverManager.getDriver() != null) {
DriverManager.getDriver().quit();
DriverManager.removeDriver();
}
}
In my experience, parallel Appium tests on a device farm require careful driver lifecycle management. The ThreadLocal pattern is not optional when running TestNG parallel='methods' — without it, threads share driver references and produce intermittent NullPointerExceptions that look like test failures but are actually framework bugs. I once spent two days debugging what appeared to be flaky login tests before realising the problem was that two threads were calling quit() on the same driver instance, leaving the third thread with a null reference.
iOS Testing with Java
Appium Java Client 9.x supports iOS through the XCUITest driver. The key differences from Android testing are in the driver class, options class, and locator strategies. iOS uses IOSDriver instead of AndroidDriver, XCUITestOptions instead of UiAutomator2Options, and a completely different set of element attributes compared to Android.
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.ios.options.XCUITestOptions;
XCUITestOptions options = new XCUITestOptions();
options.setPlatformName("iOS");
options.setDeviceName("iPhone 15");
options.setBundleId("com.example.app");
options.setWdaLaunchDelay(Duration.ofSeconds(5));
IOSDriver iosDriver = new IOSDriver(new URL("http://127.0.0.1:4723"), options);
// iOS-specific locators
iosDriver.findElement(AppiumBy.iOSNsPredicateString(
"type == 'XCUIElementTypeTextField' AND name == 'username'"));
iosDriver.findElement(AppiumBy.iOSClassChain(
"**/XCUIElementTypeButton[`label == 'Login'`]"));
iOS requires Xcode and a Mac for XCUITest to work. You cannot run iOS automation on Linux or Windows — the XCUITest driver communicates with the iOS Simulator or a real device through Apple's private frameworks that are only available on macOS. For CI on non-Mac hosts, you will need to either use a Mac CI agent or a cloud device farm like BrowserStack or Sauce Labs.
The setWdaLaunchDelay is specific to iOS and accounts for the time WebDriverAgent (WDA) takes to launch on the device. WDA is Appium's XCUITest-based server that runs on the iOS device, similar to UIAutomator2's APK server on Android. On a real iPhone, WDA can take 10-30 seconds to launch on the first run (faster on subsequent runs because it stays installed). Without an appropriate launch delay, the Appium server may attempt to start the session before WDA is ready, causing a connection refused error.
iOS predicate strings are Apple's own element selection language and are much faster than XPath for iOS automation. A predicate string query runs natively within XCUITest and returns results faster than an equivalent XPath query that must traverse the full element tree. The class chain locator is similar but supports hierarchical traversal — use it when you need to find an element relative to a parent container.
CI/CD with GitHub Actions
Running Appium Android tests in a GitHub Actions pipeline requires a macOS runner because Android emulator hardware acceleration is not available on Linux GitHub runners (as of 2026). The workflow below sets up the full Android environment, creates an emulator, starts Appium, and runs the TestNG suite.
name: Appium Android Tests
on: [push, pull_request]
jobs:
appium-test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Create Emulator
run: |
avdmanager create avd -n "Pixel_6" -k "system-images;android-33;google_apis;x86_64"
emulator -avd Pixel_6 -no-audio -no-window &
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done'
- name: Install Appium
run: |
npm install -g appium@2.x
appium driver install uiautomator2
appium server --port 4723 &
sleep 5
- name: Run tests
run: mvn test -Dsurefire.suiteXmlFiles=testng.xml
- name: Upload report
uses: actions/upload-artifact@v4
if: always()
with:
name: appium-report
path: reports/
The emulator boot sequence is handled by a shell loop that polls sys.boot_completed via ADB. This is more reliable than a fixed sleep because boot time varies depending on the runner's hardware performance. The loop exits as soon as the property returns "1", meaning the emulator is fully booted and ready for test execution.
The if: always() condition on the report upload step ensures that the HTML report artifact is uploaded even when tests fail — which is exactly when you most need to see the report. Without if: always(), the upload step would be skipped on test failure and you would have no artefact to diagnose the failure from.
Best Practices
After years of building and maintaining Java Appium frameworks in enterprise environments, here are the ten practices that make the biggest difference between a maintainable, reliable framework and one that becomes a source of frustration and distrust.
1. Always use ThreadLocal for parallel driver management. Never store the AndroidDriver or IOSDriver as a static field or a non-ThreadLocal instance field. Any parallel execution configuration in testng.xml will cause intermittent failures if the driver is shared across threads.
2. Prefer ACCESSIBILITY_ID over XPath for Android. The accessibility ID locator uses UIAutomator2's native content-description lookup, which is significantly faster than XPath tree traversal. Work with your development team to ensure all interactive elements have meaningful accessibility IDs set in the Android layout XML.
3. Use AppiumBy static methods instead of raw By strings. AppiumBy.accessibilityId("value") is type-safe and readable. The old pattern of MobileBy.AccessibilityId("value") is deprecated in Appium Java Client 9.x.
4. Never hardcode device serials or IP addresses. Read device identifiers from environment variables (System.getenv("DEVICE_SERIAL")) or a JSON/YAML config file. This makes your framework work on any machine without code changes.
5. Add explicit waits after every navigation action. After clicking a button that triggers a screen transition, wait for a landmark element on the new screen before proceeding. Never assume a navigation completed instantaneously.
6. Use UiScrollable for scrolling to elements. The UIAutomator2 UiScrollable API is more reliable than gesture-based swipes for reaching off-screen elements in lists and recycler views. It handles variable content lengths and different device screen sizes automatically.
7. Capture screenshots on every test failure via TestNG ITestListener. Use an ITestListener implementation rather than putting screenshot logic in @AfterMethod, so that the screenshot is captured at the exact moment of failure rather than after teardown begins.
8. Reset app state in @BeforeMethod, not @BeforeClass. Class-level setup runs once per class, meaning state bleeds between test methods. Method-level setup ensures each test starts from a clean, predictable state, which is essential for reliable parallel execution.
9. Test on multiple Android API levels. Test on at least API 30 (Android 11), API 33 (Android 13), and API 34 (Android 14). UIAutomator2 behaviour and element attribute names differ across API levels, and tests that pass on API 33 can fail on API 30 due to differences in the accessibility framework.
10. Build a DriverFactory class. Create a single factory class that accepts a platform parameter ("android" or "ios") and returns the correctly configured driver. This gives your framework a single entry point for driver creation and makes it easy to add new platforms or device configurations in the future.