Appium tests that only run on a developer's laptop are not CI tests — they are manual tests with extra steps. The value of mobile automation comes from running it automatically on every pull request, every merge to main, and every nightly release candidate. This lesson shows how to run Appium tests in GitHub Actions and Jenkins, covering the environment setup that trips most teams up.
The CI challenge for mobile
Web automation in CI is straightforward: install a browser, run Selenium. Mobile automation has more moving parts:
- Android tests need a JDK, Android SDK, an emulator, and Appium
- iOS tests need macOS, Xcode, a Simulator or a connected device, and Appium
- Emulators/Simulators must be booted before tests start
- Boot time varies (2–4 minutes) and must be waited on reliably
GitHub Actions provides Ubuntu and macOS hosted runners. Android tests work on both (emulators run on Ubuntu). iOS tests require macOS runners.
GitHub Actions: Android emulator workflow
Create .github/workflows/android-tests.yml:
name: Android Appium Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
android-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android emulator
run: |
sdkmanager "system-images;android-34;google_apis;x86_64"
sdkmanager "emulator"
sdkmanager "platform-tools"
echo "no" | avdmanager create avd \
--name "test_avd" \
--package "system-images;android-34;google_apis;x86_64" \
--device "pixel_6"
- name: Start Android emulator
run: |
emulator -avd test_avd -no-window -no-audio -no-snapshot-load &
adb wait-for-device
until [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" = "1" ]; do
sleep 3
done
echo "Emulator is ready"
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Appium
run: |
npm install -g appium
appium driver install uiautomator2
- name: Start Appium server
run: appium --log appium.log &
- name: Wait for Appium to be ready
run: |
until curl -s http://127.0.0.1:4723/status | grep -q '"ready":true'; do
sleep 2
done
- name: Run tests
run: mvn test -Dtest=AndroidSmokeTest
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
target/surefire-reports/
target/screenshots/
appium.logKey points:
emulator ... &starts the emulator in the background- The
untilloop pollssys.boot_completedrather than sleeping a fixed time - Appium server also starts in the background; the
curlloop waits for the status endpoint if: always()on the upload step ensures artifacts are saved even when tests fail
GitHub Actions: iOS Simulator workflow
name: iOS Appium Tests
on:
push:
branches: [main]
jobs:
ios-tests:
runs-on: macos-14 # M1/M2 runners support Simulators
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Boot iOS Simulator
run: |
xcrun simctl boot "iPhone 15 Pro" || true
xcrun simctl list devices | grep "Booted"
- name: Install Appium
run: |
npm install -g appium
appium driver install xcuitest
- name: Start Appium server
run: appium --log appium.log &
- name: Run tests
run: mvn test -Dtest=IOSSmokeTest
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-test-results
path: target/surefire-reports/macOS GitHub Actions runners are more expensive than Ubuntu. Keep iOS CI lean by running only smoke tests on every PR and the full suite on nightly builds.
Jenkins pipeline
For a Jenkins pipeline, create a Jenkinsfile in the project root:
pipeline {
agent { label 'android-agent' } // agent with Android SDK pre-installed
environment {
ANDROID_HOME = '/opt/android-sdk'
PATH = "${ANDROID_HOME}/emulator:${ANDROID_HOME}/platform-tools:${env.PATH}"
JAVA_HOME = tool 'JDK17'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Start Emulator') {
steps {
sh '''
emulator -avd ci_avd -no-window -no-audio -no-snapshot-load &
adb wait-for-device
until [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" = "1" ]; do
sleep 3
done
'''
}
}
stage('Start Appium') {
steps {
sh 'appium --log ${WORKSPACE}/appium.log &'
sh '''
until curl -s http://127.0.0.1:4723/status | grep -q "ready"; do
sleep 2
done
'''
}
}
stage('Run Tests') {
steps {
sh 'mvn test'
}
}
}
post {
always {
junit 'target/surefire-reports/*.xml'
archiveArtifacts artifacts: 'target/screenshots/**, appium.log', allowEmptyArchive: true
}
failure {
echo 'Tests failed — check archived screenshots and Appium log'
}
}
}Jenkins HTML report issue
When you archive JMeter or HTML reports in Jenkins, the Content Security Policy blocks JavaScript. The same applies to any HTML report (Extent Reports, Surefire HTML). To allow scripts in archived reports:
// In Jenkins script console (Manage Jenkins → Script Console)
System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "")Or add it to JAVA_OPTS in Jenkins startup configuration.
Connecting CI to cloud providers
For cloud-based tests in CI, store credentials as secrets:
# GitHub Actions — use repository secrets
- name: Run BrowserStack tests
env:
BS_USERNAME: ${{ secrets.BS_USERNAME }}
BS_ACCESS_KEY: ${{ secrets.BS_ACCESS_KEY }}
run: mvn test -Dtarget=browserstackIn Jenkins, use the Credentials plugin and inject with withCredentials:
withCredentials([
string(credentialsId: 'bs-username', variable: 'BS_USERNAME'),
string(credentialsId: 'bs-access-key', variable: 'BS_ACCESS_KEY')
]) {
sh 'mvn test -Dtarget=browserstack'
}Test selection strategy in CI
Not every test should run on every trigger. A practical tiering:
| Trigger | Tests to run |
|---|---|
| Pull request | Smoke tests (5–10 tests, ~5 min) |
| Merge to main | Full regression on emulator (~30 min) |
| Nightly | Full regression on real device cloud (multi-device matrix) |
| Pre-release tag | Full suite + manual exploratory |
Implement with Maven Surefire groups:
# Smoke tests only
mvn test -Dgroups=smoke
# Full regression
mvn test -Dgroups=smoke,regressionTag tests with TestNG groups:
@Test(groups = {"smoke", "regression"})
public void loginTest() { ... }
@Test(groups = {"regression"})
public void advancedFeatureTest() { ... }