Detox CI/CD: Running React Native E2E Tests on GitHub Actions

Detox CI/CD: Running React Native E2E Tests on GitHub Actions

Running Detox tests locally is one thing. Getting them running reliably in CI is another. Mobile E2E tests have historically been the most painful part of CI/CD pipelines: emulators crash, builds are slow, and tests that pass locally fail for reasons that are hard to diagnose.

This guide covers everything you need to set up a reliable Detox pipeline on GitHub Actions — iOS and Android, caching strategies, test sharding, artifact collection, and common failure modes.

The Challenge

Mobile CI/CD has several constraints that web CI doesn't:

  • Slow builds: compiling a React Native app takes minutes, not seconds
  • Emulator instability: Android emulators are memory-intensive and sometimes crash
  • macOS-only for iOS: iOS simulators require macOS runners, which are more expensive on GitHub Actions
  • Parallelism complexity: splitting tests across multiple machines requires coordination

The strategies in this guide address each of these.

Basic iOS Workflow

Start with a minimal iOS workflow:

# .github/workflows/e2e-ios.yml
name: E2E Tests (iOS)

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  e2e-ios:
    name: Detox E2E  iOS
    runs-on: macos-14
    timeout-minutes: 60

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install CocoaPods
        run: |
          cd ios
          pod install
        env:
          NO_FLIPPER: 1

      - name: Install applesimutils
        run: |
          brew tap wix/brew
          brew install applesimutils

      - name: Install Detox CLI
        run: npm install -g detox-cli

      - name: Build app for testing
        run: detox build --configuration ios.sim.release

      - name: Run Detox tests
        run: detox test --configuration ios.sim.release --headless --record-logs all

      - name: Upload artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: detox-artifacts-ios
          path: artifacts/

Basic Android Workflow

Android requires an emulator, which GitHub Actions supports via hardware acceleration on Linux machines:

# .github/workflows/e2e-android.yml
name: E2E Tests (Android)

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  e2e-android:
    name: Detox E2E  Android
    runs-on: ubuntu-latest
    timeout-minutes: 60

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Detox CLI
        run: npm install -g detox-cli

      - name: Enable KVM (hardware acceleration)
        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: Build app for testing
        run: detox build --configuration android.emu.release

      - name: Run Detox tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          arch: x86_64
          profile: Pixel_4
          avd-name: Pixel_4_API_33
          script: detox test --configuration android.emu.release --headless --record-logs all

      - name: Upload artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: detox-artifacts-android
          path: artifacts/

Caching Strategies

Build times dominate mobile CI. Three caches matter most:

Gradle Cache (Android)

- name: Cache Gradle
  uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
    restore-keys: gradle-

CocoaPods Cache (iOS)

- name: Cache Pods
  uses: actions/cache@v4
  id: pods-cache
  with:
    path: ios/Pods
    key: pods-${{ hashFiles('ios/Podfile.lock') }}

- name: Install CocoaPods
  if: steps.pods-cache.outputs.cache-hit != 'true'
  run: cd ios && pod install

App Binary Cache

Cache the compiled app binary to skip rebuilds when code hasn't changed:

- name: Cache app binary
  uses: actions/cache@v4
  id: app-cache
  with:
    path: ios/build
    key: app-ios-${{ hashFiles('ios/**', 'src/**', 'package-lock.json') }}

- name: Build app
  if: steps.app-cache.outputs.cache-hit != 'true'
  run: detox build --configuration ios.sim.release

With these caches, a typical PR run goes from 20 minutes to under 8 minutes.

Test Sharding (Parallelism)

For large test suites, shard across multiple machines. Detox supports sharding natively:

jobs:
  e2e-ios:
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3]
        total-shards: [3]
    
    steps:
      # ... setup steps ...
      
      - name: Run Detox tests (shard ${{ matrix.shard }}/${{ matrix.total-shards }})
        run: |
          detox test \
            --configuration ios.sim.release \
            --headless \
            --shard-index ${{ matrix.shard }} \
            --shards-count ${{ matrix.total-shards }}

This splits your test suite across 3 machines and runs them in parallel. A 30-minute suite becomes a 10-minute suite.

Artifacts and Reporting

Collect screenshots, logs, and videos from test runs for debugging:

- name: Run Detox tests
  run: |
    detox test \
      --configuration ios.sim.release \
      --headless \
      --record-logs all \
      --take-screenshots failing \
      --record-videos failing \
      --artifacts-location artifacts/

- name: Upload test artifacts
  if: always()  # Upload even on success to check for flaky tests
  uses: actions/upload-artifact@v4
  with:
    name: detox-artifacts-${{ github.run_id }}
    path: artifacts/
    retention-days: 7

For test reports, Detox outputs JUnit XML by default, which GitHub Actions can display:

// e2e/jest.config.js
module.exports = {
  reporters: [
    'detox/runners/jest/reporter',
    ['jest-junit', { outputDirectory: 'test-results', outputName: 'junit.xml' }],
  ],
};
- name: Publish test results
  uses: dorny/test-reporter@v1
  if: always()
  with:
    name: Detox Tests
    path: test-results/junit.xml
    reporter: java-junit

Detox Configuration for CI

Update your .detoxrc.js to use release builds in CI (faster, more representative of production):

module.exports = {
  configurations: {
    'ios.sim.debug': {
      device: 'simulator',
      app: 'ios.debug',
    },
    'ios.sim.release': {
      device: 'simulator',
      app: 'ios.release',
    },
    'android.emu.debug': {
      device: 'emulator',
      app: 'android.debug',
    },
    'android.emu.release': {
      device: 'emulator',
      app: 'android.release',
    },
  },
  apps: {
    'ios.release': {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/YourApp.app',
      build: 'xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
    },
    'android.release': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
      build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release',
    },
  },
};

Handling Flaky Tests

Mobile tests can be flaky even with Detox's synchronization. Use retry logic:

// e2e/jest.config.js
module.exports = {
  testRunner: {
    args: { '$0': 'jest' },
    jest: {
      testEnvironmentOptions: {
        // Retry failed tests up to 2 times
      },
    },
  },
};

Or via CLI:

detox test --configuration ios.sim.release --retries 2

Track flaky test rate over time. If a test is flaky more than 5% of runs, it needs investigation — not more retries.

Common Failure Modes

"Simulator not found": The simulator name in .detoxrc.js doesn't match what's installed. Check with xcrun simctl list devices.

"Failed to build": Usually a code signing issue. Use CODE_SIGNING_ALLOWED=NO in the build command:

xcodebuild ... CODE_SIGNING_ALLOWED=NO

"ANR — app not responding" on Android: The emulator is under memory pressure. Use --avd-name with a device that has lower screen resolution.

Tests timeout in CI but pass locally: CI machines are slower. Increase Detox's waitForIdleTimeout in .detoxrc.js and Jest's testTimeout in jest.config.js.

Emulator crashes mid-suite: Enable KVM (see the KVM step above) and check emulator memory. The reactivecircus/android-emulator-runner action handles most emulator lifecycle issues.

Cost Optimization

iOS macOS runners cost ~10x more than Linux runners on GitHub Actions. Optimize:

  1. Run iOS only on main branch or before production deploys, not every PR
  2. Cache aggressively — skip rebuilds when source hasn't changed
  3. Shard tests — reduce total wall time even if compute cost stays the same
  4. Run Android on PRs (cheaper) and iOS only after merge
jobs:
  e2e-ios:
    if: github.ref == 'refs/heads/main'
    runs-on: macos-14
    # ...

  e2e-android:
    runs-on: ubuntu-latest
    # ...

Summary

A well-configured Detox CI/CD pipeline gives you confidence that your React Native app works end-to-end before every release. The investment is meaningful — configuring mobile CI properly takes effort — but the alternative is shipping regressions to production users.

Start with Android (cheaper, easier to set up), get that stable, then add iOS. Add caching from day one — build time is the biggest CI cost. Add sharding only when your test suite grows beyond 15 minutes. And monitor flakiness rates; a reliable test suite is more valuable than a fast flaky one.

Read more