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 installApp 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.releaseWith 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: 7For 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-junitDetox 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 2Track 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:
- Run iOS only on main branch or before production deploys, not every PR
- Cache aggressively — skip rebuilds when source hasn't changed
- Shard tests — reduce total wall time even if compute cost stays the same
- 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.