A handful of Appium scripts is easy; a suite that runs on several devices in parallel, reports clearly and runs in CI needs a framework. This guide covers the project structure, thread-safe driver management for parallel execution on multiple devices, hybrid app testing, reporting with Extent and Allure, and a GitHub Actions pipeline.

Complete Appium Test Framework Structure

appium-framework/
├── src/
│   ├── main/java/
│   │   ├── base/
│   │   │   ├── BaseTest.java        // driver init/quit
│   │   │   └── BasePage.java        // shared page methods
│   │   ├── pages/
│   │   │   ├── LoginPage.java
│   │   │   ├── HomePage.java
│   │   │   └── ProductPage.java
│   │   ├── utils/
│   │   │   ├── DriverManager.java   // ThreadLocal driver
│   │   │   ├── ConfigReader.java    // read config.properties
│   │   │   ├── GestureUtils.java    // swipe/scroll/tap
│   │   │   ├── ScreenshotUtils.java
│   │   │   └── WaitUtils.java
│   │   └── constants/
│   │       └── AppConstants.java    // package names, timeouts
│   └── test/
│       ├── java/tests/
│       │   ├── LoginTest.java
│       │   └── SearchTest.java
│       ├── listeners/
│       │   └── TestListener.java    // screenshot on fail
│       └── resources/
│           ├── config.properties
│           ├── testng.xml
│           └── testdata/
│               └── login_data.xlsx
├── reports/
├── screenshots/
└── pom.xml

DriverManager.java — ThreadLocal for parallel safety

public class DriverManager {
    private static final ThreadLocal<AndroidDriver> driverThread = new ThreadLocal<>();
    public static AndroidDriver getDriver() {
        return driverThread.get();
    }
    public static void setDriver(AndroidDriver driver) {
        driverThread.set(driver);
    }
    public static void quitDriver() {
        if (getDriver() != null) {
            getDriver().quit();
            driverThread.remove();
        }
    }
    public static AndroidDriver createAndroidDriver() throws Exception {
        UiAutomator2Options options = new UiAutomator2Options();
        options.setPlatformName("Android");
        options.setDeviceName(ConfigReader.get("device.name"));
        options.setAppPackage(ConfigReader.get("app.package"));
        options.setAppActivity(ConfigReader.get("app.activity"));
        options.setNoReset(true);
        AndroidDriver driver = new AndroidDriver(new URL("http://127.0.0.1:4723"), options);
        setDriver(driver);
        return driver;
    }
}
Advertisement

Parallel Execution on Multiple Devices

<!-- testng.xml — run on 2 devices in parallel -->
<suite name="MobileSuite" parallel="tests" thread-count="2">
    <test name="AndroidTest_Pixel6">
        <parameter name="deviceName" value="Pixel_6_API_33"/>
        <parameter name="udid" value="emulator-5554"/>
        <classes>
            <class name="tests.LoginTest"/>
        </classes>
    </test>
    <test name="AndroidTest_Samsung">
        <parameter name="deviceName" value="Samsung_Galaxy_S21"/>
        <parameter name="udid" value="R3CT102WXYZ"/>
        <classes>
            <class name="tests.LoginTest"/>
        </classes>
    </test>
</suite>
// BaseTest.java — read device from TestNG parameter
@BeforeMethod
@Parameters({"deviceName","udid"})
public void setUp(String deviceName, String udid) throws Exception {
    UiAutomator2Options options = new UiAutomator2Options();
    options.setDeviceName(deviceName);
    options.setUdid(udid);
    // ... other caps
    AndroidDriver driver = new AndroidDriver(serverUrl, options);
    DriverManager.setDriver(driver);
}

Note: Use ThreadLocal<AndroidDriver> so each parallel test thread has its own driver instance. Shared static driver causes race conditions and test failures.

Hybrid App & WebView Testing

// Hybrid app: part native, part WebView
// Step 1: Get all contexts
Set<String> contexts = driver.getContextHandles();
for (String ctx : contexts) {
    System.out.println(ctx);
    // Prints: NATIVE_APP
    //         WEBVIEW_com.example.myapp
}
// Step 2: Switch to WebView
String webviewCtx = contexts.stream()
    .filter(c -> c.contains("WEBVIEW"))
    .findFirst()
    .orElseThrow(() -> new RuntimeException("No WebView found"));
driver.context(webviewCtx);
// Step 3: Use Selenium-style locators inside WebView
driver.findElement(By.cssSelector(".submit-button")).click();
driver.findElement(By.xpath("//input[@type='email']")).sendKeys("test@test.com");
// Step 4: Switch back to native
driver.context("NATIVE_APP");
// Debugging: enable WebView debugging (in app source code)
// WebView.setWebContentsDebuggingEnabled(true);

Test Reporting with Extent Reports

// TestListener.java
public class TestListener implements ITestListener {
    private static ExtentReports extent;
    private static ThreadLocal<ExtentTest> test = new ThreadLocal<>();
    @Override
    public void onStart(ITestContext ctx) {
        ExtentSparkReporter spark = new ExtentSparkReporter("reports/AppiumReport.html");
        spark.config().setDocumentTitle("Appium Test Report");
        extent = new ExtentReports();
        extent.attachReporter(spark);
    }
    @Override
    public void onTestStart(ITestResult result) {
        test.set(extent.createTest(result.getMethod().getMethodName()));
    }
    @Override
    public void onTestFailure(ITestResult result) {
        test.get().fail(result.getThrowable());
        String shot = ScreenshotUtils.capture(DriverManager.getDriver(),
                                               result.getName());
        test.get().addScreenCaptureFromPath(shot);
    }
    @Override
    public void onTestSuccess(ITestResult result) {
        test.get().pass("Test passed");
    }
    @Override
    public void onFinish(ITestContext ctx) { extent.flush(); }
}

CI/CD with GitHub Actions

# .github/workflows/appium-tests.yml
name: Appium Android Tests
on: [push, pull_request]
jobs:
  appium-test:
    runs-on: macos-latest   # macOS has hardware acceleration for emulators
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'
    - name: Set up Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
    - name: Install Appium & driver
      run: |
        npm install -g appium
        appium driver install uiautomator2
    - name: Start Android emulator
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 33
        script: |
          appium &
          sleep 5
          mvn test
    - name: Upload report
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: appium-report
        path: reports/

Appium with Allure Reporting

<!-- pom.xml -->
<dependency>
    <groupId>io.qameta.allure</groupId>
    <artifactId>allure-testng</artifactId>
    <version>2.25.0</version>
</dependency>
// Test class with Allure annotations
@Feature("Login")
public class LoginTest extends BaseTest {
    @Test
    @Story("Valid login")
    @Description("User logs in with valid credentials and sees home screen")
    @Severity(SeverityLevel.CRITICAL)
    public void testValidLogin() {
        LoginPage login = new LoginPage(driver);
        Allure.step("Enter username", () ->
            login.enterUsername("admin@test.com"));
        Allure.step("Enter password", () ->
            login.enterPassword("Pass@123"));
        Allure.step("Click login", login::clickLogin);
        HomePage home = new HomePage(driver);
        Allure.step("Verify home screen", () ->
            Assert.assertTrue(home.isVisible()));
    }
}
// Generate report:
// mvn test
// allure serve target/allure-results

Parallel Device Checklist

  • One driver per thread (ThreadLocal), created and quit in the test lifecycle.
  • A unique appium:udid per device and a unique appium:systemPort (Android) or appium:wdaLocalPort (iOS) per session.
  • Independent test data: two devices must never log in as the same user in conflicting tests.
  • Device details passed from testng.xml or a device pool, not hard-coded in tests.

FAQs

How do you run Appium tests on multiple devices in parallel?

Give each session its own device UDID and unique system port (or WDA port on iOS), keep the driver in a ThreadLocal, and run TestNG with parallel tests or classes, one per device.

Why is systemPort needed for parallel Android tests?

Each UiAutomator2 session uses a local port to talk to the device; two sessions on the same port collide, so every parallel session needs a different systemPort.

Can Appium run in GitHub Actions?

Yes. Start an Android emulator in the runner (for example with an emulator action), start the Appium server, then run the Maven tests; iOS jobs need macOS runners.