Mobile E2E Testing in CI: GitHub Actions, Detox, Maestro, and Emulator Setup
Running mobile E2E tests locally is one thing. Getting them to run reliably in CI is another problem entirely. You're dealing with emulators that need time to boot, builds that need to be cached, iOS simulators that require macOS runners, and Android emulators that need KVM acceleration or careful configuration on Linux.
This guide covers the full CI setup for mobile E2E testing: GitHub Actions workflows for both Android and iOS, working Detox and Maestro configurations, emulator optimization, artifact collection on failure, and parallelization patterns. No hand-waving — actual YAML that works.
The CI Problem with Mobile Testing
Before diving into configuration, understand what makes mobile CI hard:
Android emulators on Linux require KVM hardware virtualization. Most GitHub-hosted runners (ubuntu-latest) support KVM since 2023, but you need to enable it explicitly. Without KVM, Android emulators use software rendering and are 10-20x slower — too slow to be useful in CI.
iOS simulators require macOS. macOS GitHub-hosted runners cost more (roughly 10x the compute cost of Linux) and are often slower to provision. Every iOS E2E test run is expensive. This pushes teams toward testing on simulators locally and using a device cloud (Maestro Cloud, BrowserStack, Sauce Labs) for CI.
Build caching is mandatory. Android Gradle builds can take 10-15 minutes from scratch. Xcode builds can take 20-30 minutes. Without caching, your CI pipeline is unusable. With caching, incremental builds take 1-3 minutes.
Emulator boot time is significant. An Android emulator takes 60-120 seconds to boot from a cold start. Creating an AVD snapshot on first run and restoring from it on subsequent runs cuts this to 15-30 seconds.
Repository Structure
Assume a React Native project with this structure:
my-app/
android/
ios/
src/
e2e/
detox/
jest.config.js
tests/
login.test.js
checkout.test.js
maestro/
flows/
login.yaml
checkout.yaml
.detoxrc.js
.github/
workflows/
e2e-android.yaml
e2e-ios.yamlAndroid E2E with Detox on GitHub Actions
# .github/workflows/e2e-android-detox.yaml
name: Android E2E (Detox)
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e-android:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | \
sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/**/*.gradle', 'android/gradle.properties') }}
restore-keys: gradle-
- name: Cache AVD snapshot
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-api33-${{ runner.os }}
- name: Create AVD and generate snapshot
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
profile: pixel_6
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "AVD snapshot created"
- name: Build Android app (debug)
run: |
cd android
./gradlew assembleDebug assembleAndroidTest \
-DtestBuildType=debug \
--no-daemon \
-Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=512m"
- name: Install Detox CLI
run: npm install -g detox-cli
- name: Run E2E tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
profile: pixel_6
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: |
adb wait-for-device
adb shell input keyevent 82
detox test \
--configuration android.emu.debug \
--headless \
--record-logs all \
--take-screenshots failing \
--record-videos failing
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: detox-artifacts-${{ github.run_id }}
path: e2e/detox/artifacts/
retention-days: 7The key pieces:
- KVM enablement — the udev rules block is required on ubuntu-latest. Without it, the emulator runs in software mode.
- AVD caching — creates the emulator image once and caches it. The cache key includes the API level but not the content — the snapshot doesn't change between runs if the AVD creation step doesn't run.
- Separate AVD creation step — only runs on cache miss. The
reactivecircus/android-emulator-runneraction boots the emulator, the script runs, then the emulator shuts down and the snapshot is captured by the cache action. --no-daemon— Gradle daemons accumulate on CI and cause memory issues. Always use this flag.disable-animations: true— disables system animations in the emulator, which reduces Detox synchronization overhead and test execution time.
Detox Configuration for CI
Your .detoxrc.js needs a CI-specific configuration:
module.exports = {
testRunner: {
args: {
'$0': 'jest',
config: 'e2e/detox/jest.config.js',
},
jest: {
setupTimeout: 300000, // 5 minutes for CI
},
},
artifacts: {
plugins: {
screenshot: {
shouldTakeAutomaticScreenshots: false,
keepOnlyFailedTestsArtifacts: true,
},
video: {
enabled: process.env.CI_RECORD_VIDEO === 'true',
},
log: {
enabled: true,
},
timeline: {
enabled: false, // Expensive, disable in CI unless debugging
},
},
rootDir: 'e2e/detox/artifacts',
},
apps: {
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug --no-daemon',
reversePorts: [8081],
},
'ios.debug': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app',
build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build | xcpretty',
},
},
devices: {
emulator: {
type: 'android.emulator',
device: { avdName: 'Pixel_6_API_33' },
},
simulator: {
type: 'ios.simulator',
device: { type: 'iPhone 15', os: 'iOS 17.0' },
},
},
configurations: {
'android.emu.debug': {
device: 'emulator',
app: 'android.debug',
},
'ios.sim.debug': {
device: 'simulator',
app: 'ios.debug',
},
},
};Android E2E with Maestro on GitHub Actions
Maestro is simpler to configure because it doesn't need a special build:
# .github/workflows/e2e-android-maestro.yaml
name: Android E2E (Maestro)
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
maestro-android:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | \
sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/**/*.gradle') }}
- name: Cache AVD
uses: actions/cache@v4
id: avd-cache
with:
path: ~/.android/avd/*
key: avd-api33
- name: Create AVD
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
script: echo "AVD ready"
- name: Build APK
run: cd android && ./gradlew assembleDebug --no-daemon
- name: Install Maestro
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
- name: Run Maestro flows
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
disable-animations: true
script: |
adb install android/app/build/outputs/apk/debug/app-debug.apk
maestro test \
--include-tags smoke \
--format junit \
--output test-results.xml \
e2e/maestro/flows/
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: maestro-results-${{ github.run_id }}
path: test-results.xml
- name: Publish test results
if: always()
uses: dorny/test-reporter@v1
with:
name: Maestro E2E
path: test-results.xml
reporter: java-junitiOS E2E with Detox on GitHub Actions
iOS requires macOS runners:
# .github/workflows/e2e-ios-detox.yaml
name: iOS E2E (Detox)
on:
push:
branches: [main]
# Only run on PRs for critical paths — iOS is expensive
pull_request:
paths:
- 'src/screens/checkout/**'
- 'src/screens/auth/**'
jobs:
e2e-ios:
runs-on: macos-14
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Cache Xcode derived data
uses: actions/cache@v4
with:
path: ios/build
key: xcode-${{ hashFiles('ios/Podfile.lock', 'ios/**/*.xcodeproj/project.pbxproj') }}
restore-keys: xcode-
- name: Install CocoaPods
run: |
cd ios
pod install --repo-update
- name: Install Detox CLI
run: npm install -g detox-cli
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Build iOS app
run: |
detox build \
--configuration ios.sim.debug \
-- \
-UseModernBuildSystem=YES
- name: Start Metro bundler
run: npx react-native start &
# Wait for Metro to be ready
- name: Wait for Metro
run: |
timeout 60 sh -c 'until curl -s http://localhost:8081/status | grep -q running; do sleep 2; done'
- name: Run E2E tests
run: |
detox test \
--configuration ios.sim.debug \
--record-logs all \
--take-screenshots failing \
--cleanup
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: ios-detox-artifacts-${{ github.run_id }}
path: e2e/detox/artifacts/
retention-days: 5The macos-14 runner includes Apple Silicon (M1) hardware, which runs iOS simulators faster than macos-latest (currently Intel). Use macos-14 unless you have a compatibility reason to use an older version.
Using Maestro Cloud for iOS (Cheaper Than macOS Runners)
For iOS, Maestro Cloud is often more cost-effective than macOS GitHub runners. Upload your app bundle and let Maestro Cloud run tests on real devices:
# .github/workflows/e2e-ios-maestro-cloud.yaml
name: iOS E2E (Maestro Cloud)
on:
push:
branches: [main]
jobs:
build-and-test:
runs-on: macos-14
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Install CocoaPods
run: cd ios && pod install
- name: Build iOS app for simulator
run: |
xcodebuild \
-workspace ios/MyApp.xcworkspace \
-scheme MyApp \
-configuration Debug \
-sdk iphonesimulator \
-derivedDataPath ios/build \
-quiet \
ONLY_ACTIVE_ARCH=NO
- name: Zip app bundle
run: |
cd ios/build/Build/Products/Debug-iphonesimulator
zip -r MyApp.zip MyApp.app
- name: Install Maestro
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
- name: Upload to Maestro Cloud and test
env:
MAESTRO_CLOUD_API_KEY: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
run: |
maestro cloud \
--apiKey $MAESTRO_CLOUD_API_KEY \
--app ios/build/Build/Products/Debug-iphonesimulator/MyApp.zip \
--include-tags smoke \
e2e/maestro/flows/This approach uses a macOS runner only for building (30-40 minutes), then offloads test execution to Maestro Cloud. You're paying less in GitHub Actions minutes but paying Maestro Cloud per test run.
Splitting Tests: PR vs Main Branch
Not all tests should run on every PR. Structure your CI to run fast tests on PRs and full test suites on main:
# .github/workflows/e2e-android-detox.yaml
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
jobs:
e2e-android:
runs-on: ubuntu-latest
env:
# On PRs, run only smoke tests. On main, run everything.
TEST_TAGS: ${{ github.event_name == 'pull_request' && 'smoke' || 'all' }}
steps:
# ... setup steps ...
- name: Run E2E tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
script: |
if [ "$TEST_TAGS" = "smoke" ]; then
detox test --configuration android.emu.debug \
--testNamePattern "smoke" \
--headless
else
detox test --configuration android.emu.debug \
--headless
fiWith Maestro:
- name: Run smoke tests (PRs)
if: github.event_name == 'pull_request'
run: maestro test --include-tags smoke e2e/maestro/flows/
- name: Run full suite (main branch)
if: github.event_name == 'push'
run: maestro test e2e/maestro/flows/Parallelizing Test Runs
For large test suites, split across multiple emulators:
jobs:
e2e-android-parallel:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
# ... setup ...
- name: Run shard ${{ matrix.shard }}/3
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
script: |
detox test \
--configuration android.emu.debug \
--headless \
--shard-index ${{ matrix.shard - 1 }} \
--shard-count 3
- name: Upload shard artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: detox-artifacts-shard-${{ matrix.shard }}
path: e2e/detox/artifacts/Detox's --shard-index and --shard-count split the test suite evenly across runners. Each matrix job runs a different subset. All three run in parallel, reducing total CI time by roughly 3x.
Build Optimization
Gradle Build Caching
Beyond caching the Gradle wrapper and caches, enable Gradle's build cache:
# android/gradle.properties
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configureondemand=true
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryErrorSkipping Unnecessary Builds
If only test files changed, skip the app build and reuse a cached artifact:
- name: Check if app rebuild needed
id: check-rebuild
run: |
if git diff --name-only HEAD~1 HEAD | grep -qE '^(android/|ios/|src/)'; then
echo "rebuild=true" >> $GITHUB_OUTPUT
else
echo "rebuild=false" >> $GITHUB_OUTPUT
fi
- name: Cache app artifact
uses: actions/cache@v4
id: app-cache
with:
path: android/app/build/outputs/apk/debug/app-debug.apk
key: apk-${{ hashFiles('android/**', 'src/**') }}
- name: Build Android app
if: steps.app-cache.outputs.cache-hit != 'true'
run: cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug --no-daemonHandling Flaky Tests in CI
Flaky E2E tests are a fact of life. Two strategies:
Automatic retry on failure:
- name: Run E2E tests with retry
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
script: |
MAX_RETRIES=2
ATTEMPT=0
until detox test --configuration android.emu.debug --headless || [ $ATTEMPT -eq $MAX_RETRIES ]; do
ATTEMPT=$((ATTEMPT+1))
echo "Test run failed, retry $ATTEMPT of $MAX_RETRIES"
adb shell input keyevent 82 # Unlock screen between retries
doneDetox's built-in retry:
// e2e/detox/jest.config.js
module.exports = {
testEnvironment: 'detox/runners/jest/testEnvironment',
testTimeout: 120000,
retryTimes: 2, // Jest retry
verbose: true,
};Or per-test in Detox:
// In your test file
it('should complete checkout', async () => {
// ...
}, { retries: 2 }); // Detox-level retryCollecting Metrics
Track test run times over time to catch performance regressions:
- name: Report test timing
if: always()
run: |
echo "Test duration: $SECONDS seconds" >> $GITHUB_STEP_SUMMARY
# If using Detox with JUnit reporter
if [ -f test-results.xml ]; then
PASS=$(grep -c 'testcase' test-results.xml)
FAIL=$(grep -c 'failure' test-results.xml)
echo "Passed: $PASS, Failed: $FAIL" >> $GITHUB_STEP_SUMMARY
fiProduction Monitoring as the Final Layer
CI tests run against builds in controlled environments. They tell you that your code works as designed. They don't tell you that your production app works for users right now.
API responses change. Third-party integrations go down. OS updates introduce rendering regressions. These don't show up in your Detox or Maestro test suite because those tests run against your build, not your production environment.
HelpMeTest provides continuous monitoring for your live app — running user flows on real devices on a schedule, alerting you when a flow breaks in production. It's the monitoring layer that your CI pipeline can't provide: real traffic, real devices, real APIs, running 24/7 after you've shipped.
The combination is: Detox/Maestro in CI to catch regressions before shipping, HelpMeTest in production to catch issues after shipping.
Summary
The working CI setup for mobile E2E testing requires:
- KVM enablement on Linux runners — one udev rule that's easy to miss
- AVD snapshot caching — eliminates 60-90 second emulator boot time
- Gradle caching — eliminates 10-15 minute cold builds
- Separate build and test steps — reuse the APK across retries
- Smoke/full test split — fast feedback on PRs, complete coverage on main
- Automatic retry for flaky tests — accept that mobile E2E has flakiness, handle it at the infrastructure level
- Artifact collection on failure — screenshots and logs are mandatory for debugging CI failures
iOS CI is expensive. Evaluate whether Maestro Cloud or another device cloud is more cost-effective than macOS GitHub runners for your test volume.