Run Detox Tests with GitHub Actions, Part 2: Caching and Parallel Tests

In part one of this two-part series, we created a GitHub Actions pipeline for running Detox tests on iOS and Android. We have just two jobs that run in parallel, where each job is responsible for building the app for the appropriate platform and then running the tests.

If you have a small app with only a couple of test cases, this might work for you. But it’s more likely that you have a lot of test cases that you want to execute and get fast feedback on.

We’ll improve our pipeline now by bringing in some caching, and also execute the Detox test cases in parallel.

1. Add caching for iOS.

We can cache the ios/Pods directory to speed up our pod install step. These dependencies don’t change very often, so we should get a nice speedup.

Add the following step just before the pod install step in your YAML file:


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

This uses the cache action provided by GitHub. It caches all the files at the provided path. Any jobs that result in the same key will be able to reuse the cache. In this case, we’re generating the key based on the runner OS and a hash of the Podfile.lock. That means we will always build from scratch whenever our dependencies change.

2. Add caching for Android.

On the Android side, we can cache all of our Gradle dependencies. Gradle provides a nice GitHub Action to make this extremely simple:


- name: Setup Gradle
  uses: gradle/actions/setup-gradle@v3
  with:
    gradle-version: wrapper
    cache-read-only: false

This will automatically cache all relevant Gradle files and directories every time your pipeline runs. You don’t have to worry about building appropriate keys or which files to cache.

3. Run test cases in parallel.

The caching steps can help speed up the build time, but they don’t really help us with the test case execution. Instead, we’ll use GitHub’s matrix feature to run each test file in parallel.

First, we need to determine which test files we have. We can add a new job to handle that:


find-test-files:
  name: Find Detox test files
  runs-on: macos-latest
  outputs:
    test-files: ${{ steps.set-test-files.outputs.test-files }}
  steps:
    - name: Checkout Code
      uses: actions/checkout@v4

    - name: Find test files
      id: set-test-files
      run: |
        TEST_FILES=$(find e2e -name '*.test.ts' | sed 's|e2e/||g' | jq -R -s -c 'split("\n")[:-1]')
        echo "test-files=$TEST_FILES" >> $GITHUB_OUTPUT

This job does a few things. It finds all test files in the e2e directory (make sure to update this if your Detox test files are somewhere else). Then it strips off the e2e/ prefix from each resulting file name, and passes that newline-separated input into jq so it can be transformed into an array of file names.

For example, if your file structure looked like this:


e2e/
    login.test.ts
    notifications.test.ts
    accountSettings.test.ts

You would end up with the TEST_FILES variable being assigned to ["login.test.ts", "notifications.test.ts", "accountSettings.test.ts"].

That TEST_FILES variable is then sent into the job output so that it can be used in later jobs.

Now, we need to use that list of files in our iOS and Android jobs to execute the tests in parallel. We can do that by indicating that each job needs the find-test-files job before it can execute, and adding a strategy block prior to the steps:


needs: [find-test-files]

strategy:
  fail-fast: false
  matrix:
    test-file: ${{ fromJson(needs.find-test-files.outputs.test-files) }}

Finally, we can adjust the test runner steps to use the matrix:


# iOS
- name: Run Detox tests
  run: npm run detox:ios:test:ci -- ${{ matrix.test-file }}

# Android
- name: Run Detox tests
  uses: reactivecircus/android-emulator-runner@v2
  with:
      api-level: 31
      arch: x86_64
      avd-name: Android_API31
      force-avd-creation: false
      emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics
      disable-animations: false
      script: npm run detox:android:test:ci -- ${{ matrix.test-file }}

Now, the pipeline will be executed for each test file! Let’s say our build steps take 10 minutes to run and we have 4 test files that each take 5 minutes to execute. It would previously have taken 30 minutes to run the pipeline (10 + 4 * 5). Now, it should only take 15 minutes since all the tests will execute in parallel.

But there’s a hidden cost here. We are now building the app 4 times, which means our total pipeline time will be 45 minutes (10 * 4 + 5). So we’ll actually end up being charged more by GitHub for the runner usage than with our previous sequential approach.

4. Split up the build and test steps

To solve this, we can add new jobs for building the app on iOS and Android. Each job will need to upload the build artifacts so that the test jobs can download them to use while executing the tests.

For iOS, it should look like this:


    build-ios:
        name: iOS - Build Detox
        runs-on: macos-13-xlarge

        steps:
            - name: Checkout Code
              uses: actions/checkout@v4
              with:
                  fetch-depth: 1

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: 22.2.0

            - name: Npm install
              run: npm install
              
        - uses: actions/cache@v4
          id: cache
          with:
              path: ios/Pods
          key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
                        
            - name: Pod install
              run: cd ios && pod install

            - name: Build Detox
              run: npm run detox:ios:build
          
        - name: Upload iOS app
          uses: actions/upload-artifact@v4
          with:
           name: ios-app-artifact
           path: ios/build/Build/Products/Debug-iphonesimulator/YourApp.app
           retention-days: 1

Android will look very similar:


    build-android:
        name: Android - Build Detox
        runs-on: ubuntu-latest

        steps:
            - name: Free Disk Space (Ubuntu)
              uses: jlumbroso/free-disk-space@main
              with:
                  tool-cache: true
                  android: false

            - name: Checkout Code
              uses: actions/checkout@v4
              with:
                  fetch-depth: 1

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: 22.2.0

            - name: Setup Java
              uses: actions/setup-java@v4
              with:
                  distribution: zulu
                  java-version: 17
                  
        - name: Setup Gradle
          uses: gradle/actions/setup-gradle@v3
          with:
          gradle-version: wrapper
          cache-read-only: false

            - name: Npm install
              run: npm install

            - name: Build Detox
              run: npm run detox:android:build
      
        - name: Upload Android app
          uses: actions/upload-artifact@v4
          with:
              name: android-app-artifact
          path: android/app/build/outputs/apk
              retention-days: 1

Both jobs are just running the same set of steps as before, up through the detox:*:build command and then uploading the appropriate artifacts.

Now, we can modify our original build and test job to just execute the tests. We still need to check out the code and run npm install so that the Detox dependencies are available.

On the iOS side, that will mean removing the Pod cache and install steps and the Detox build step. For Android, you can remove the Java and Gradle setup steps and the Detox build step.

You must also add a step to download the artifacts before running the Node setup step.


# iOS
- name: Download iOS app
  uses: actions/download-artifact@v4
  with:
    name: ios-app-artifact
    path: ios/build/Build/Products/Debug-iphonesimulator/YourApp.app

# Android
- name: Download Android app
  uses: actions/download-artifact@v4
  with:
    name: android-app-artifact
    path: android/app/build/outputs/apk

Finally, add the build-[ios/android] job to the needs array for each testing job, and you should be good to go! If we consider the same example from step 3, it should take 30 minutes of pipeline time (10 + 4 * 5) to run the tests, but only 15 minutes of clock time!

Your final pipeline should look like this.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *