Mobile E2E Testing in CI: GitHub Actions, Detox, Maestro, and Emulator Setup

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.yaml

Android 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: 7

The key pieces:

  1. KVM enablement — the udev rules block is required on ubuntu-latest. Without it, the emulator runs in software mode.
  2. 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.
  3. Separate AVD creation step — only runs on cache miss. The reactivecircus/android-emulator-runner action boots the emulator, the script runs, then the emulator shuts down and the snapshot is captured by the cache action.
  4. --no-daemon — Gradle daemons accumulate on CI and cause memory issues. Always use this flag.
  5. 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-junit

iOS 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: 5

The 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
            fi

With 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:+HeapDumpOnOutOfMemoryError

Skipping 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-daemon

Handling 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
            done

Detox'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 retry

Collecting 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
          fi

Production 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:

  1. KVM enablement on Linux runners — one udev rule that's easy to miss
  2. AVD snapshot caching — eliminates 60-90 second emulator boot time
  3. Gradle caching — eliminates 10-15 minute cold builds
  4. Separate build and test steps — reuse the APK across retries
  5. Smoke/full test split — fast feedback on PRs, complete coverage on main
  6. Automatic retry for flaky tests — accept that mobile E2E has flakiness, handle it at the infrastructure level
  7. 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.

Read more