Detox CI Integration: Running React Native E2E Tests in GitHub Actions and GitLab
Running Detox tests in CI requires matching simulator/emulator availability to your runner OS, caching dependencies, and handling the slow parts (app builds, simulator boot) efficiently. This guide covers complete CI configurations for GitHub Actions and GitLab CI for both iOS and Android.
Key Takeaways
- iOS requires macOS runners — this is non-negotiable; GitHub's macOS runners cost ~10x Linux.
- Build the app once, test multiple times — cache the
.appand.apkartifacts across test runs to avoid rebuild costs. - Android emulators on Linux need KVM or nested virtualization — configure the emulator action correctly or tests will hang on boot.
- Detox artifacts capture screenshots and video — configure
artifactsLocationand upload withactions/upload-artifactfor debugging failures. - Parallel execution with multiple simulators requires splitting test files and launching independent simulator instances.
Getting Detox running locally is straightforward. Getting it running reliably in CI requires understanding a handful of platform constraints, caching strategies, and failure modes that aren't obvious until you hit them.
iOS CI Requirements
iOS simulators only run on macOS. No Linux equivalent exists. This means:
- GitHub Actions: use
macos-latestormacos-14runners (Apple Silicon) - GitLab CI: use a macOS runner (self-hosted or a paid cloud provider)
- Self-hosted Mac minis: popular for teams running many iOS builds
macOS runner costs on GitHub Actions are approximately 10x Linux runner costs. Optimizing your Detox iOS pipeline — caching builds, running only on PR or merge, parallelizing — has real cost impact.
Android CI Requirements
Android emulators run on Linux but require hardware acceleration:
- GitHub Actions: the
reactivecircus/android-emulator-runneraction handles KVM setup - Self-hosted Linux: enable KVM (
sudo kvm-okto verify) - Cloud CI (CircleCI, GitLab, etc.): use machine executors (not Docker containers) — containers don't support KVM
GitHub Actions: iOS
# .github/workflows/detox-ios.yml
name: Detox iOS E2E
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
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 applesimutils
run: |
brew tap wix/brew
brew install applesimutils
- name: Cache Detox build
uses: actions/cache@v4
id: detox-build-cache
with:
path: ios/build
key: detox-ios-${{ hashFiles('ios/**', 'package-lock.json') }}
restore-keys: |
detox-ios-
- name: Install CocoaPods
if: steps.detox-build-cache.outputs.cache-hit != 'true'
run: |
cd ios && pod install --repo-update
- name: Build iOS app for Detox
if: steps.detox-build-cache.outputs.cache-hit != 'true'
run: npx detox build --configuration ios.sim.debug
- name: Run Detox tests
run: npx detox test --configuration ios.sim.debug --headless --record-logs all --take-screenshots failing
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: detox-ios-artifacts
path: artifacts/
retention-days: 7Key decisions in this config:
Cache the build directory (ios/build). The xcodebuild step takes 10–20 minutes. Cache it keyed on iOS and package changes — only rebuild when something relevant changes.
--headless flag runs tests without opening a simulator window. Required in CI environments without a display.
--record-logs all --take-screenshots failing captures diagnostic information. You want screenshots of failed states and device logs when debugging CI failures that don't reproduce locally.
Always upload artifacts (if: always()) — you need the screenshots and logs even (especially) when tests fail.
GitHub Actions: Android
# .github/workflows/detox-android.yml
name: Detox Android E2E
on:
pull_request:
branches: [main]
jobs:
e2e-android:
runs-on: ubuntu-latest
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: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle-wrapper.properties') }}
- name: Cache Detox APK
uses: actions/cache@v4
id: apk-cache
with:
path: android/app/build/outputs/apk
key: detox-android-${{ hashFiles('android/**', 'package-lock.json') }}
- name: Build Android APK
if: steps.apk-cache.outputs.cache-hit != 'true'
run: npx detox build --configuration android.emu.debug
- name: Run Android Emulator + Detox tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 31
arch: x86_64
profile: Pixel_4
avd-name: Pixel_4_API_31
script: npx detox test --configuration android.emu.debug --headless --record-logs all
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: detox-android-artifacts
path: artifacts/
retention-days: 7The reactivecircus/android-emulator-runner action handles:
- AVD creation
- KVM setup
- Emulator boot (waits for fully booted state)
- Cleanup after tests
Without it, you'd need to manage all of this manually — the action is worth using.
GitLab CI: iOS
# .gitlab-ci.yml
detox-ios:
stage: test
tags:
- macos
- xcode
only:
- merge_requests
- main
timeout: 60 minutes
cache:
key:
files:
- ios/Podfile.lock
- package-lock.json
paths:
- ios/build/
- node_modules/
before_script:
- npm ci
- brew tap wix/brew && brew install applesimutils
- cd ios && pod install && cd ..
script:
- npx detox build --configuration ios.sim.debug
- npx detox test --configuration ios.sim.debug --headless --record-logs all --take-screenshots failing
artifacts:
when: always
paths:
- artifacts/
expire_in: 7 daysGitLab's tags key selects which runner executes the job. You need a macOS runner registered with those tags for iOS jobs.
Configuring Detox Artifacts
Tell Detox where to store artifacts in your detox.config.js:
module.exports = {
// ... apps, devices, configurations ...
artifacts: {
rootDir: 'artifacts',
pathBuilder: './e2e/artifactsPathBuilder.js', // optional custom naming
plugins: {
instruments: { enabled: false },
log: {
enabled: true,
keepOnlyFailedTestsArtifacts: false,
},
uiHierarchy: 'enabled',
screenshot: {
shouldTakeAutomaticSnapshots: true,
keepOnlyFailedTestsArtifacts: true,
takeWhen: {
testDone: false,
testFailed: true,
},
},
video: {
enabled: false, // enable for debugging flaky tests
},
},
},
};For most CI runs, screenshots on failure + logs is the right balance. Video is extremely helpful for debugging flaky tests but generates large files.
Parallel Test Execution
Running tests in parallel requires:
- Multiple simulators or emulators
- Test file sharding
- Separate Detox workers
# GitHub Actions matrix for parallel iOS execution
jobs:
e2e-ios:
runs-on: macos-14
strategy:
matrix:
shard: [1, 2, 3]
steps:
# ... setup steps ...
- name: Run Detox tests (shard ${{ matrix.shard }} of 3)
run: |
npx detox test \
--configuration ios.sim.debug \
--headless \
--shard-index ${{ matrix.shard }} \
--shard-count 3Detox's built-in sharding splits test files evenly across shards. With 3 shards and 30 test files, each shard runs 10 files. The total wall-clock time drops to ~1/3 of the serial execution time.
Important: each shard needs its own simulator. With matrix execution, each job gets a fresh runner with its own simulator. Sharding on a single machine with multiple simultaneous simulators requires explicit simulator boot and Detox worker configuration.
Handling Simulator Boot Time
Simulators take 30–60 seconds to boot. On CI, you're paying for this every run. Strategies to minimize it:
Keep simulators booted (self-hosted runners only): boot the simulator as part of the runner startup script, not as part of the job. Jobs then find a warm simulator.
Use --reuse flag: detox test --reuse tells Detox to reuse an already-running app instead of reinstalling. Only valid when you know the binary hasn't changed.
Cache simulator state: GitHub Actions doesn't support simulator state caching, but self-hosted runners can use a pre-configured simulator image.
Environment Variables in Tests
Pass environment variables to the app under test using launch arguments:
// detox.config.js
configurations: {
'ios.sim.ci': {
device: 'simulator',
app: 'ios.debug',
behavior: {
init: {
exposeGlobals: true,
},
},
},
},// In tests
await device.launchApp({
launchArgs: {
API_URL: process.env.API_URL || 'https://staging.api.example.com',
MOCK_AUTH: 'true',
},
});For CI, set environment variables in the workflow and they'll flow through to the test process.
Failing Fast and Retry Logic
Detox has a built-in retry mechanism for flaky tests:
npx detox test --configuration ios.sim.debug --retries 2With --retries 2, failed tests are retried up to 2 times before marking as definitively failed. This handles intermittent infrastructure flakiness (slow emulator, network blip) without masking real failures.
Don't set retries higher than 2. If a test fails consistently on retry, it's either genuinely failing or fundamentally flaky — fix it instead of retrying.
Pull Request vs. Full Suite Strategy
Running the full Detox suite on every commit is expensive. A tiered approach:
| Trigger | Tests to run |
|---|---|
| PR opened/updated | Smoke tests (critical path only, ~10 tests) |
| Merge to main | Full E2E suite |
| Scheduled (nightly) | Full suite + real device tests |
Implement this with path filtering and branch conditions in your CI config:
on:
pull_request:
paths:
- 'src/**'
- 'e2e/**'
- 'ios/**'
- 'android/**'This skips the E2E workflow entirely for documentation or config-only changes.
Debugging CI-Only Failures
Tests that pass locally but fail in CI are usually caused by:
- Different simulator version — match your CI simulator exactly to local development
- Missing applesimutils — install on the CI runner
- Build cache stale — clear the cache and rebuild
- Animations enabled — CI simulators may have animations enabled; disable them with
UIAnimationDragCoefficient=0in simulator settings or via the Detoxinitconfiguration - Network conditions — your staging API may be unavailable or slow in CI; use mock adapters for network-dependent tests
The artifacts (screenshots + logs) from failed CI runs should tell you what was on screen at the time of failure. If you're flying blind without them, the first step is configuring artifact upload.