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.