A well-organised Maven project reduces cognitive load when tests break at 2am and you need to find the right file fast. This lesson covers the directory layout, package naming conventions, and configuration file placement that experienced Appium-Java teams converge on.
Recommended directory layout
appium-java-suite/
├── pom.xml
├── testng.xml
├── src/
│ └── test/
│ ├── java/
│ │ └── com/example/
│ │ ├── base/
│ │ │ ├── BaseTest.java
│ │ │ └── DriverManager.java
│ │ ├── pages/
│ │ │ ├── LoginPage.java
│ │ │ └── HomePage.java
│ │ ├── tests/
│ │ │ └── LoginTest.java
│ │ └── utils/
│ │ ├── WaitUtils.java
│ │ └── GestureUtils.java
│ └── resources/
│ ├── apps/
│ │ ├── app-debug.apk
│ │ └── app-debug.ipa
│ └── config/
│ └── capabilities.json
Keep test code in src/test/java. Appium suites are test-only projects — there is no src/main/java unless you share utilities with a non-test module.
Package responsibilities
base/ — BaseTest holds the @BeforeSuite, @BeforeTest, and @AfterTest lifecycle. DriverManager wraps the ThreadLocal<AppiumDriver> for parallel safety. No test logic here.
pages/ — Page objects only. Each class maps to one screen. No assertions, no test data — just locators and action methods.
tests/ — TestNG test classes. Assertions live here. One test class per feature area.
utils/ — Reusable helpers. WaitUtils wraps WebDriverWait; GestureUtils wraps W3C Actions. Neither class depends on pages or tests.
BaseTest pattern
package com.example.base;
import io.appium.java_client.AppiumDriver;
import org.testng.annotations.AfterTest;
import org.testng.annotations.BeforeTest;
import org.testng.annotations.Parameters;
public abstract class BaseTest {
@BeforeTest
@Parameters("platform")
public void setUp(String platform) {
DriverManager.initDriver(platform);
}
@AfterTest
public void tearDown() {
DriverManager.quitDriver();
}
protected AppiumDriver getDriver() {
return DriverManager.getDriver();
}
}Test classes extend BaseTest — they never touch driver creation directly.
DriverManager with ThreadLocal
package com.example.base;
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.MalformedURLException;
import java.net.URL;
public class DriverManager {
private static final ThreadLocal<AppiumDriver> driverThreadLocal = new ThreadLocal<>();
public static void initDriver(String platform) {
AppiumDriver driver;
try {
URL serverUrl = new URL("http://127.0.0.1:4723");
if ("Android".equalsIgnoreCase(platform)) {
UiAutomator2Options options = new UiAutomator2Options()
.setDeviceName("emulator-5554")
.setApp(System.getProperty("user.dir") + "/src/test/resources/apps/app-debug.apk");
driver = new AndroidDriver(serverUrl, options);
} else {
XCUITestOptions options = new XCUITestOptions()
.setDeviceName("iPhone 15")
.setApp(System.getProperty("user.dir") + "/src/test/resources/apps/app-debug.ipa");
driver = new IOSDriver(serverUrl, options);
}
} catch (MalformedURLException e) {
throw new RuntimeException("Invalid Appium server URL", e);
}
driverThreadLocal.set(driver);
}
public static AppiumDriver getDriver() {
return driverThreadLocal.get();
}
public static void quitDriver() {
AppiumDriver driver = driverThreadLocal.get();
if (driver != null) {
driver.quit();
driverThreadLocal.remove();
}
}
}The ThreadLocal means each test thread gets its own driver instance. When testng.xml runs Android and iOS tests in parallel, they don't share a driver.
Capabilities from a JSON file
Hardcoding capabilities in DriverManager creates maintenance friction when device names change. A cleaner approach reads them from resources/config/capabilities.json:
{
"android": {
"deviceName": "emulator-5554",
"platformVersion": "13",
"app": "src/test/resources/apps/app-debug.apk"
},
"ios": {
"deviceName": "iPhone 15",
"platformVersion": "17",
"app": "src/test/resources/apps/app-debug.ipa"
}
}Parse with Jackson (com.fasterxml.jackson.databind.ObjectMapper) and inject into the options object. This separates device configuration from framework code, which matters when different CI environments target different devices.
What goes in testng.xml
testng.xml is the entry point for the suite — it declares which test classes run and which parameters they receive:
<suite name="Mobile Suite" parallel="tests" thread-count="2">
<test name="Android Smoke">
<parameter name="platform" value="Android"/>
<classes>
<class name="com.example.tests.LoginTest"/>
</classes>
</test>
<test name="iOS Smoke">
<parameter name="platform" value="iOS"/>
<classes>
<class name="com.example.tests.LoginTest"/>
</classes>
</test>
</suite>Run it: mvn test -DsuiteXmlFile=testng.xml.