Learn how to set up and run E2E tests on EAS Build with popular libraries such as Detox.
EAS Build support for E2E testing is in a very early state. The intention of this guide is to explain how you can run E2E tests on the service today, without all of the affordances that we plan to build in the future. This guide will evolve over time as support for testing workflows in EAS Build improves.
With EAS Build, you can build a workflow for running E2E tests for your application. In this guide, you will learn how to use one of the most popular libraries (Detox) to do that.
This guide explains how to run E2E tests with Detox in a bare workflow project. You can use @config-plugins/detox
for a managed project, but you may need to adjust some of the instructions in this guide in order to do so.
Let's start by initializing a new Expo project, installing and configuring @config-plugins/detox
, and running npx expo prebuild
to generate the native projects.
Start with the following commands:
# Initialize a new project
-
npx create-expo-app eas-tests-example
# cd into the project directory
-
cd eas-tests-example
# Install @config-plugins/detox
-
npm install --save-dev @config-plugins/detox
Now, open app.json and add the @config-plugins/detox
plugin to your plugins
list (this must be done before prebuilding). This will automatically configure the Android native code to support Detox.
{
"expo": {
// ...
"plugins": ["@config-plugins/detox"]
}
}
Run prebuild to generate the native projects:
-
npx expo prebuild
The first step to writing E2E tests is to have something to test - we have an empty app, so let's make our app interactive. We can add a button and display some new text when it's pressed. Later, we're going to write a test that's going to tap the button and check whether the text has been displayed.
import { StatusBar } from 'expo-status-bar';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
export default function App() {
const [clicked, setClicked] = useState(false);
return (
<View style={styles.container}>
{!clicked && (
<Pressable testID="click-me-button" style={styles.button} onPress={() => setClicked(true)}>
<Text style={styles.text}>Click me</Text>
</Pressable>
)}
{clicked && <Text style={styles.hi}>Hi!</Text>}
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
hi: {
fontSize: 30,
color: '#4630EB',
},
button: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 32,
borderRadius: 4,
elevation: 3,
backgroundColor: '#4630EB',
},
text: {
fontSize: 16,
lineHeight: 21,
fontWeight: 'bold',
letterSpacing: 0.25,
color: 'white',
},
});
Let's add two development dependencies to the project - jest
and detox
. jest
(or mocha
) is required because detox
does not have its own test-runner.
# Install jest & detox
-
npm install --save-dev jest detox
# Create Detox configuration files
-
npx detox init -r jest
See the official Detox docs at https://wix.github.io/Detox/docs/introduction/getting-started/ and https://wix.github.io/Detox/docs/guide/jest to learn about any potential updates to this process.
Detox requires you to specify both the build command and path to the binary produced by it. Technically, the build command is not necessary when running tests on EAS Build, but allows you to run tests locally (for example, using npx detox build --configuration ios.release
).
Edit detox.config.js and replace the configuration with:
/** @type {Detox.DetoxConfig} */
module.exports = {
logger: {
level: process.env.CI ? 'debug' : undefined,
},
testRunner: {
$0: 'jest',
args: {
config: 'e2e/jest.config.js',
_: ['e2e'],
},
},
artifacts: {
plugins: {
log: process.env.CI ? 'failing' : undefined,
screenshot: 'failing',
},
},
apps: {
'ios.release': {
type: 'ios.app',
build:
'xcodebuild -workspace ios/eastestsexample.xcworkspace -scheme eastestsexample -configuration Release -sdk iphonesimulator -arch x86_64 -derivedDataPath ios/build',
binaryPath:
'ios/build/Build/Products/Release-iphonesimulator/eastestsexample.app',
},
'android.release': {
type: 'android.apk',
build:
'cd android && ./gradlew :app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release && cd ..',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
},
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 14',
},
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'pixel_4',
},
},
},
configurations: {
'ios.release': {
device: 'simulator',
app: 'ios.release',
},
'android.release': {
device: 'emulator',
app: 'android.release',
},
},
};
Next, we'll add our first E2E tests. Delete the auto-generated e2e/firstTest.e2e.js and create our own e2e/homeScreen.e2e.js with the following contents:
describe('Home screen', () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('"Click me" button should be visible', async () => {
await expect(element(by.id('click-me-button'))).toBeVisible();
});
it('shows "Hi!" after tapping "Click me"', async () => {
await element(by.id('click-me-button')).tap();
await expect(element(by.text('Hi!'))).toBeVisible();
});
});
There are two tests in the suite:
Both tests assume the button has the testID
set to click-me-button
. See the source code for details.
Now that we have configured Detox and written our first E2E test, let's configure EAS Build and run the tests in the cloud.
The following command creates eas.json in the project's root directory:
-
eas build:configure
There are a few more steps to configure EAS Build for running E2E tests as part of the build:
apk
file).applesimutils
command line util.Edit eas.json and add the test
build profile:
{
"build": {
"test": {
"android": {
"gradleCommand": ":app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release",
"withoutCredentials": true
},
"ios": {
"simulator": true
}
}
}
}
Create eas-hooks/eas-build-pre-install.sh that installs the necessary tools and dependencies for the given platform:
#!/usr/bin/env bash
set -eox pipefail
if [[ "$EAS_BUILD_RUNNER" == "eas-build" && "$EAS_BUILD_PROFILE" == "test"* ]]; then
if [[ "$EAS_BUILD_PLATFORM" == "android" ]]; then
sudo apt-get --quiet update --yes
# Install emulator & video bridge dependencies
# Source: https://github.com/react-native-community/docker-android/blob/master/Dockerfile
sudo apt-get --quiet install --yes \
libc6 \
libdbus-1-3 \
libfontconfig1 \
libgcc1 \
libpulse0 \
libtinfo5 \
libx11-6 \
libxcb1 \
libxdamage1 \
libnss3 \
libxcomposite1 \
libxcursor1 \
libxi6 \
libxext6 \
libxfixes3 \
zlib1g \
libgl1 \
pulseaudio \
socat
# Emulator must be API 31 -- API 32 and 33 fail due to https://github.com/wix/Detox/issues/3762
sdkmanager --install "system-images;android-31;google_apis;x86_64"
avdmanager --verbose create avd --force --name "pixel_4" --device "pixel_4" --package "system-images;android-31;google_apis;x86_64"
else
brew tap wix/brew
brew install applesimutils
fi
fi
Next, create eas-hooks/eas-build-on-success.sh with the following contents. The script runs different commands for Android and iOS. For iOS, the only command is detox test
. For Android, it's a bit more complicated. You'll have to start the emulator prior to running the tests as detox
sometimes seems to be having problems with starting the emulator on its own and it can get stuck on running the first test from your test suite. After the detox test
run, there is a command that kills the previously started emulator.
#!/usr/bin/env bash
function cleanup()
{
echo 'Cleaning up...'
if [[ "$EAS_BUILD_PLATFORM" == "android" ]]; then
# Kill emulator
adb emu kill &
fi
}
if [[ "$EAS_BUILD_PROFILE" != "test" ]]; then
exit
fi
# Fail if anything errors
set -eox pipefail
# If this script exits, trap it first and clean up the emulator
trap cleanup EXIT
ANDROID_EMULATOR=pixel_4
if [[ "$EAS_BUILD_PLATFORM" == "android" ]]; then
# Start emulator
$ANDROID_SDK_ROOT/emulator/emulator @$ANDROID_EMULATOR -no-audio -no-boot-anim -no-window -use-system-libs 2>&1 >/dev/null &
# Wait for emulator
max_retry=10
counter=0
until adb shell getprop sys.boot_completed; do
sleep 10
[[ counter -eq $max_retry ]] && echo "Failed to start the emulator!" && exit 1
counter=$((counter + 1))
done
# Execute Android tests
if [[ "$EAS_BUILD_PROFILE" == "test" ]]; then
detox test --configuration android.release
fi
else
# Execute iOS tests
if [[ "$EAS_BUILD_PROFILE" == "test" ]]; then
detox test --configuration ios.release
fi
fi
Edit package.json to use EAS Build hooks to run the above scripts on EAS Build:
{
"scripts": {
"eas-build-pre-install": "./eas-hooks/eas-build-pre-install.sh",
"eas-build-on-success": "./eas-hooks/eas-build-on-success.sh"
}
}
Don't forget to add executable permissions to eas-build-pre-install.sh and eas-build-on-success.sh. Run
chmod +x eas-hooks/*.sh
.
Running the tests on EAS Build is like running a regular build:
-
eas build -p all -e test
If you have set up everything correctly you should see the successful test result in the build logs:
This step is optional but highly recommended.
When an E2E test case fails, it can be helpful to see the screenshot of the application state. EAS Build makes it easy to upload any arbitrary build artifacts using the buildArtifactPaths
field in eas.json.
Detox supports taking in-test screenshots of the device. The detox.config.js sample above includes a line to configure Detox to take screenshots of failed tests.
Edit eas.json and add buildArtifactPaths
to the test
build profile:
{
"build": {
"test": {
"android": {
"gradleCommand": ":app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release",
"withoutCredentials": true
},
"ios": {
"simulator": true
},
"buildArtifactPaths": ["artifacts/**/*.png"]
}
}
}
In contrast to applicationArchivePath
, the build artifacts defined at buildArtifactPaths
will be uploaded even if the build fails. All .png
files from the artifacts
directory will be packed into a tarball and uploaded to AWS S3. You can download them later from the build details page.
If you run E2E tests locally, remember to add artifacts
to .gitignore
:
artifacts/
To test the new configuration, let's break a test and see that EAS Build uploads the screenshots.
Edit e2e/homeScreen.e2e.js and make the following change:
Run an iOS build with the following command and wait for it to finish:
-
eas build -p ios -e test
After going to the build details page you should see that the build failed. Use the "Download artifacts" button to download and examine the screenshot:
The full example from this guide is available at https://github.com/expo/eas-tests-example.
The above guide explains how to run E2E tests against a release build of your project, which requires executing a full native build before each test run. Re-building the native app each time you run E2E tests may not be desirable if only the project JavaScript or assets have changed. However, this is necessary for release builds because the app JavaScript bundle is embedded into the binary.
Instead, we can use development builds to load from a local development server or from published updates to save time and CI resources. This can be done by having your E2E test runner invoke the app with a URL that points to a specific update bundle URL, as described in the development builds deep linking URLs guide.
Development builds typically display an onboarding welcome screen when an app is launched for the first time, which intends to provide context about the expo-dev-client
UI for developers. However, it can interfere with your E2E tests (which expect to interact with your app and not an onboarding screen). To skip the onboarding screen in a test environment, the query parameter disableOnboarding=1
can be appended to the project URL (an EAS Update URL or a local development server URL).
An example of such a Detox test is shown below. Full example code is available on the eas-tests-example repository.
const { openApp } = require('./utils/openApp');
describe('Home screen', () => {
beforeEach(async () => {
await openApp();
});
it('"Click me" button should be visible', async () => {
await expect(element(by.id('click-me-button'))).toBeVisible();
});
it('shows "Hi!" after tapping "Click me"', async () => {
await element(by.id('click-me-button')).tap();
await expect(element(by.text('Hi!'))).toBeVisible();
});
});
const appConfig = require('../../../app.json');
const { resolveConfig } = require('detox/internals');
const platform = device.getPlatform();
module.exports.openApp = async function openApp() {
const config = await resolveConfig();
if (config.configurationName.split('.')[1] === 'debug') {
return await openAppForDebugBuild(platform);
} else {
return await device.launchApp({
newInstance: true,
});
}
};
async function openAppForDebugBuild(platform) {
const deepLinkUrl = process.env.EXPO_USE_UPDATES
? // Testing latest published EAS update for the test_debug channel
getDeepLinkUrl(getLatestUpdateUrl())
: // Local testing with packager
getDeepLinkUrl(getDevLauncherPackagerUrl(platform));
if (platform === 'ios') {
await device.launchApp({
newInstance: true,
});
sleep(3000);
await device.openURL({
url: deepLinkUrl,
});
} else {
await device.launchApp({
newInstance: true,
url: deepLinkUrl,
});
}
await sleep(3000);
}
const getDeepLinkUrl = (url) =>
`eastestsexample://expo-development-client/?url=${encodeURIComponent(url)}`;
const getDevLauncherPackagerUrl = (platform) =>
`http://localhost:8081/index.bundle?platform=${platform}&dev=true&minify=false&disableOnboarding=1`;
const getLatestUpdateUrl = () =>
`https://u.expo.dev/${getAppId()}?channel-name=test_debug&disableOnboarding=1`;
const getAppId = () => appConfig?.expo?.extra?.eas?.projectId ?? '';
const sleep = (t) => new Promise((res) => setTimeout(res, t));
/** @type {Detox.DetoxConfig} */
module.exports = {
logger: {
level: process.env.CI ? 'debug' : undefined,
},
testRunner: {
$0: 'jest',
args: {
config: 'e2e/jest.config.js',
_: ['e2e'],
},
},
artifacts: {
plugins: {
log: process.env.CI ? 'failing' : undefined,
screenshot: 'failing',
},
},
apps: {
'ios.debug': {
type: 'ios.app',
build:
'xcodebuild -workspace ios/eastestsexample.xcworkspace -scheme eastestsexample -configuration Debug -sdk iphonesimulator -arch x86_64 -derivedDataPath ios/build',
binaryPath:
'ios/build/Build/Products/Debug-iphonesimulator/eastestsexample.app',
},
'ios.release': {
type: 'ios.app',
build:
'xcodebuild -workspace ios/eastestsexample.xcworkspace -scheme eastestsexample -configuration Release -sdk iphonesimulator -arch x86_64 -derivedDataPath ios/build',
binaryPath:
'ios/build/Build/Products/Release-iphonesimulator/eastestsexample.app',
},
'android.debug': {
type: 'android.apk',
build:
'cd android && ./gradlew :app:assembleDebug :app:assembleAndroidTest -DtestBuildType=debug && cd ..',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
},
'android.release': {
type: 'android.apk',
build:
'cd android && ./gradlew :app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release && cd ..',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
},
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 14',
},
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'pixel_4',
},
},
},
configurations: {
'ios.debug': {
device: 'simulator',
app: 'ios.debug',
},
'ios.release': {
device: 'simulator',
app: 'ios.release',
},
'android.debug': {
device: 'emulator',
app: 'android.debug',
},
'android.release': {
device: 'emulator',
app: 'android.release',
},
},
};
#!/usr/bin/env bash
function cleanup()
{
echo 'Cleaning up...'
if [[ "$EAS_BUILD_PLATFORM" == "android" ]]; then
# Kill emulator
adb emu kill &
fi
}
if [[ "$EAS_BUILD_PROFILE" != "test"* ]]; then
exit
fi
# Fail if anything errors
set -eox pipefail
# If this script exits, trap it first and clean up the emulator
trap cleanup EXIT
ANDROID_EMULATOR=pixel_4
if [[ "$EAS_BUILD_PLATFORM" == "android" ]]; then
# Start emulator
$ANDROID_SDK_ROOT/emulator/emulator @$ANDROID_EMULATOR -no-audio -no-boot-anim -no-window -use-system-libs 2>&1 >/dev/null &
# Wait for emulator
max_retry=10
counter=0
until adb shell getprop sys.boot_completed; do
sleep 10
[[ counter -eq $max_retry ]] && echo "Failed to start the emulator!" && exit 1
counter=$((counter + 1))
done
# Execute Android tests
if [[ "$EAS_BUILD_PROFILE" == "test" ]]; then
detox test --configuration android.release
fi
if [[ "$EAS_BUILD_PROFILE" == "test_debug" ]]; then
detox test --configuration android.debug
fi
else
# Execute iOS tests
if [[ "$EAS_BUILD_PROFILE" == "test" ]]; then
detox test --configuration ios.release
fi
if [[ "$EAS_BUILD_PROFILE" == "test_debug" ]]; then
detox test --configuration ios.debug
fi
fi
{
"build": {
"test": {
"android": {
"gradleCommand": ":app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release",
"withoutCredentials": true
},
"ios": {
"simulator": true
}
},
"test_debug": {
"android": {
"gradleCommand": ":app:assembleDebug :app:assembleAndroidTest -DtestBuildType=debug",
"withoutCredentials": true
},
"ios": {
"buildConfiguration": "Debug",
"simulator": true
},
"env": {
"EXPO_USE_UPDATES": "1"
},
"channel": "test_debug"
}
}
}