Adding Detox End 2 End Testing to a React Native App Part III

Rosen Toshev
9 min readSep 24, 2023

--

Back in May 2021, I have written a blog post titled Setting up Detox on an Expo-managed React Native project which dealt with setting up detox on a project that uses the expo command-line interface (link below). While this blog post may be valuable if your projects uses the Expo SDK, the goal of the current blog post is to continue as a part three of the testing series and use Detox with our react native app that already has Mock Service Worker, Redux, and GraphQL setup.

Github Repo (checkout the adding-detox-3 branch): https://github.com/rosen777/RNTestLibraryApp

Part II: https://bit.ly/3XCqKt1

Blog Post from May 2021: https://bit.ly/44swiZ3

Yoga and FBReactNativeSpec Errors

If you have an error related to a Detox file after installing Detox and running the app. In the node_modules/react-native/ReactCommon/yoga/yoga/Yoga.cpp file the | need to be changed to || in lines 2232 and 3008 (snippets below).

    node->setLayoutHadOverflow(
node->getLayout().hadOverflow() ||
currentRelativeChild->getLayout().hadOverflow());
    node->setLayoutHadOverflow(
node->getLayout().hadOverflow() ||
(collectedFlexItemsValues.remainingFreeSpace < 0));

The solution was found in the following Stack Overflow thread.

In addition, you may encounter an error related to FBReactNativeSpec. I have upgraded the version of react native of the repo to resolve this error using the two commands below.

npx react-native upgrade 0.69.11
npx @rnx-kit/align-deps --requirements react-native@0.69

If the second command doesn’t work for you, manually change the react-native to 0.69 in the package.json and change it back after it runs.

Setting Up Detox

To set up Detox, you need to install the AppleSimulatorUtils running the following commands.

brew tap wix/brew
brew install applesimutils

Now add Detox to your project following the Project Setup in the Detox official documentation. Run the following commands in the root directory of the project.

yarn add detox --dev
detox init

Change the content of the autogenerated .detoxrc file in the root directory to reflect the iOS simulators and Android emulators on your computer. This is my configuration.

/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
$0: 'jest',
config: 'e2e/jest.config.js',
},
jest: {
setupTimeout: 120000,
},
},
apps: {
'ios.debug': {
type: 'ios.app',
binaryPath:
'ios/build/Build/Products/Debug-iphonesimulator/RNTestLibraryApp.app',
build:
'xcodebuild -workspace ios/RNTestLibraryApp.xcworkspace -scheme RNTestLibraryApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
},
'ios.release': {
type: 'ios.app',
binaryPath:
'ios/build/Build/Products/Release-iphonesimulator/RNTestLibraryApp.app',
build:
'xcodebuild -workspace ios/RNTestLibraryApp.xcworkspace -scheme RNTestLibraryApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
build:
'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
reversePorts: [8081],
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
build:
'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release',
},
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 14',
},
},
attached: {
type: 'android.attached',
device: {
adbName: '.*',
},
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_5_API_31_arm64',
},
},
},
configurations: {
'ios.sim.debug': {
device: 'simulator',
app: 'ios.debug',
},
'ios.sim.release': {
device: 'simulator',
app: 'ios.release',
},
'android.att.debug': {
device: 'attached',
app: 'android.debug',
},
'android.att.release': {
device: 'attached',
app: 'android.release',
},
'android.emu.debug': {
device: 'emulator',
app: 'android.debug',
},
'android.emu.release': {
device: 'emulator',
app: 'android.release',
},
},
};

Change the jest configuration for the detox suite to make sure that only test files with the .detox.js extension are compiled.

module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.detox.js'],
testTimeout: 120000,
maxWorkers: 1,
verbose: true,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
};

Change the jest.config.js in the root directory to make sure that the unit and integration tests ran with the yarn test command DO NOT run in the e2e folder where the detox tests are.

module.exports = {
preset: 'react-native',
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|react-native-elements|react-native-dropdown-picker|@react-native(-community)?)/)',
],
setupFiles: ['./src/setupFile.js'],
testPathIgnorePatterns: ['<rootDir>/e2e'],
};

Finally, in the e2e directory rename the file from starter.test.js to starter.detox.js as we run the detox tests only on files ending with detox.js. You can now run the detox command to run the e2e tests in debug mode. Make sure that the metro bundler is running in parallel in another Terminal or tab by executing the yarn start command.

detox test --configuration ios.sim.debug

First Detox Tests

You are ready to write your first detox tests. Exciting, isn’t it? We would first like to start simple and check if the name and age elements are on the screen. In the e2e folder delete the content of the starter.detox.js file. We would need to launch the app on our device. In order to do this in the beforeAll method of the test we write the following:

  beforeAll(async () => {
await device.launchApp();
});

Since we only need to reload the React Native JS bundle before each test, which is much faster than device.launchApp() we will use device.reloadReactNative() in the beforeEach block that does that

  beforeEach(async () => {
await device.reloadReactNative();
});

To your User.js file add testID attributes to the Your name is and Your age is Text components.

    <View style={styles.wrapper}>
<Text testID={'name'} style={styles.text}>
Your name is{' '}
</Text>
...
<Text testID={'age'} style={styles.text}>
Your age is{' '}
</Text>
...

Now let’s add the tests that make sure the name and age elements are on the screen. This would be very straightforward and you can reference the official Detox documentation to test different expect methods.

  it('should have a name element', async () => {
await expect(element(by.id('name'))).toBeVisible();
});

it('should have an age element', async () => {
await expect(element(by.id('age'))).toBeVisible();
});

At this stage, our starter.detox.js file should look like this.

describe('Example', () => {
beforeAll(async () => {
await device.launchApp();
});

beforeEach(async () => {
await device.reloadReactNative();
});

it('should have a name element', async () => {
await expect(element(by.id('name'))).toBeVisible();
});

it('should have an age element', async () => {
await expect(element(by.id('age'))).toBeVisible();
});
});

Now in your Terminal run the following command to run the detox tests in debug mode. Make sure that the metro bundler is running in parallel in another Terminal or tab with the yarn start command.

detox test --configuration ios.sim.debug

You should see all the tests passing now.

Final Detox Tests

While the tests above work they only test whether the nameand age components are visible, which is not very helpful. Since the goal of end-to-end tests is “to simulate what a real user scenario looks like”, it would be a good idea to test that when a user types in an input text, the inputted text appears on the screen.

First, the App.js file should be changed to add testIDs for App.username.text, App.userage.text, App.phone.text. The full App.js file should now look like this.

import React, {useState, useEffect} from 'react';
import {
SafeAreaView,
View,
Text,
StyleSheet,
TextInput,
Button,
} from 'react-native';
import {useSelector, useDispatch} from 'react-redux';
import {setUserName, setUserAge, setUserPhone} from './redux/slices/userSlice';
import {useQuery} from '@apollo/client';
import {getCountries} from './services/countries/getCountries';
import DropDownPicker from 'react-native-dropdown-picker';

const User = () => {
const userName = useSelector(state => state.user.name);
const userAge = useSelector(state => state.user.age);
const userPhone = useSelector(state => state.user.phone);
const dispatch = useDispatch();
const [isLoading, setIsLoading] = useState(false);
const [openCountries, setOpenCountries] = useState(false);
const {loading, error, data} = useQuery(getCountries);
const [items, setItems] = useState([]);
const [value, setValue] = useState('');
const [countries, setCountries] = useState([]);
const [countriesError, setCountriesError] = useState();
const [phone, setPhone] = useState('');
const [name, setName] = useState('');
const [age, setAge] = useState('');
useEffect(() => {
if (loading) {
setIsLoading(true);
} else {
setIsLoading(false);
if (error) {
setCountriesError(error);
}
if (data) {
handleTransformDropdown(data);
setCountries(data);
}
}
}, [loading, data, error]);
const onChangeUserName = name => {
dispatch(setUserName(name));
};
const onChangeUserAge = age => {
dispatch(setUserAge(age));
};
const onChangePhoneNumber = phoneString => {
const phoneWithCountryCode = `${value}${phoneString}`;
dispatch(setUserPhone(phoneWithCountryCode));
};
const handleSubmit = () => {
onChangeUserName(name);
onChangeUserAge(age);
onChangePhoneNumber(phone);
};
const handleTransformDropdown = countriesData => {
if (countriesData && items.length === 0) {
const dropdownData = countriesData.countries.reduce(
(acc, currValue, currIndex) => {
if (
!acc.includes({
value: currValue.phone,
label: `${currValue.emoji} +${currValue.phone}`,
index: currIndex.toString(),
countryCode: currValue.phone,
})
) {
if (currValue.phone === '44') {
setValue(currValue.phone);
}
acc.push({
value: currValue.phone,
label: `${currValue.emoji} +${currValue.phone}`,
index: currIndex.toString(),
countryCode: currValue.phone,
});
}
return acc;
},
[],
);
setItems(dropdownData);
}
};
return (
<SafeAreaView>
{isLoading ? (
<View style={styles.loadingWrapper}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
) : (
<View style={styles.wrapper}>
<Text testID={'name'} style={styles.text}>
Your name is{' '}
</Text>
<Text testID={'App.username.text'} style={styles.text}>
{userName}
</Text>
<TextInput
style={styles.input}
onChangeText={text => setName(text)}
value={name}
placeholder="Name"
testID="App.username"
/>
<Text testID={'age'} style={styles.text}>
Your age is{' '}
</Text>
<Text testID={'App.userage.text'} style={styles.text}>
{userAge}
</Text>
<TextInput
style={styles.input}
onChangeText={text => setAge(text)}
value={age}
placeholder="Userage"
testID="App.userage"
/>
<View>
<Text style={styles.text}>Your phone is </Text>
<Text style={styles.text} testID="App.phone.text">
{userPhone}
</Text>
<View style={styles.dropDownWrapper}>
<DropDownPicker
open={openCountries}
value={value}
items={items}
onPress={setOpenCountries}
setOpen={setOpenCountries}
setValue={setValue}
setItems={setItems}
placeholder="Country"
labelProps={{
numberOfLines: 1,
}}
testID="App.CountryPicker"
/>
<TextInput
style={styles.fullInput}
onChangeText={text => setPhone(text)}
value={phone}
placeholder="Phone"
testID="App.phone"
keyboardType="phone-pad"
/>
</View>
</View>
<View>
<Button
testID="App.submit"
title="Submit"
onPress={() => handleSubmit()}
/>
</View>
</View>
)}
</SafeAreaView>
);
};
export default User;
const styles = StyleSheet.create({
input: {
margin: 12,
borderWidth: 1,
padding: 10,
},
fullInput: {
height: 40,
margin: 12,
borderWidth: 1,
padding: 10,
width: 230,
bottom: 8,
},
inputContainer: {
justifyContent: 'flex-start',
flexGrow: 1,
},
loadingText: {
fontSize: 18,
fontWeight: 'bold',
},
loadingWrapper: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
text: {
fontSize: 14,
marginLeft: 20,
},
dropDownWrapper: {
marginHorizontal: 12,
marginTop: 20,
maxWidth: 130,
flexDirection: 'row',
},
wrapper: {
flexGrow: 1,
marginTop: 20,
justifyContent: 'space-around',
},
submitButton: {
border: 0,
lingHeight: 2.5,
paddingVertical: 20,
width: 80,
alignItems: 'center',
borderRadius: 8,
backgroundColor: '#7393B3',
},
});

Following this, we would change the first two tests. An important thing to note is that the name, age, and phone would only be populated after the Submit button is pressed. According to the Detox documentation, the tap() action simulates a tap on the element at the specified point, or at the element’s activation point.

  it('should have a name element', async () => {
await element(by.id('App.username')).typeText('John Doe');
await element(by.id('App.submit')).tap();
await expect(element(by.id('App.username.text'))).toHaveText('John Doe');
});

it('should have an age element', async () => {
await element(by.id('App.userage')).typeText('35');
await element(by.id('App.submit')).tap();
await expect(element(by.id('App.userage.text'))).toHaveText('35');
});

You can now rerun the detox test suite on the Terminal. Make sure that the metro bundler is running in parallel.

detox test --configuration ios.sim.debug

The updated tests should now pass as well.

First Detox E2E tests in Terminal

Now let’s write a third test that checks whether after a country code is selected from a dropdown picker, a phone number is typed in the input text box, and the Submit button is pressed, the phone number is accurately reflected in the Text box with testID App.phone.text. We need to figure out how to select a country code from the dropdown. For this purpose, we would use the swipe action. The swipe direction should be set to down. We need to set values to normalizedOffset, which is the swipe amount relative to the screen width/height. In addition, we would use the normalizedStartingPointYthat represents the Y coordinate of the swipe starting point, relative to the element height. The third test would be the following:

  it('should select a country from the dropdown picker list', async () => {
await element(by.id('App.CountryPicker')).tap();
await element(by.id('App.CountryPicker')).swipe(
'down',
'fast',
0.2,
NaN,
0.1,
);
await element(by.text('🇦🇩 +376')).tap({
x: 0,
y: 0,
});
await element(by.id('App.phone')).tap();
await element(by.id('App.phone')).typeText('888333666');
await element(by.id('App.submit')).tap();
await expect(element(by.id('App.phone.text'))).toHaveText('376888333666');
});

The full starter.detox.js file should now look like this.

import {setupServer} from 'msw/node';
import {handlers} from '../src/mocks/handlers';
setupServer();
describe('Example', () => {
beforeAll(async () => {
await device.launchApp();
});

beforeEach(async () => {
await device.reloadReactNative();
});

it('should have a name element', async () => {
await element(by.id('App.username')).typeText('John Doe');
await element(by.id('App.submit')).tap();
await expect(element(by.id('App.username.text'))).toHaveText('John Doe');
});

it('should have an age element', async () => {
await element(by.id('App.userage')).typeText('35');
await element(by.id('App.submit')).tap();
await expect(element(by.id('App.userage.text'))).toHaveText('35');
});

it('should select a country from the dropdown picker list', async () => {
await element(by.id('App.CountryPicker')).tap();
await element(by.id('App.CountryPicker')).swipe(
'down',
'fast',
0.2,
NaN,
0.1,
);
await element(by.text('🇦🇩 +376')).tap({
x: 0,
y: 0,
});
await element(by.id('App.phone')).tap();
await element(by.id('App.phone')).typeText('888333666');
await element(by.id('App.submit')).tap();
await expect(element(by.id('App.phone.text'))).toHaveText('376888333666');
});
});

From your root folder, you can now run all detox tests in debug mode with the command below. Make sure that the metro bundler is running in parallel in another Terminal or tab by executing the yarn start command.

detox test --configuration ios.sim.debug

All tests should now pass. You can congratulate yourself for successfully implementing Detox and writing the end-to-end tests.

Final Detox E2E tests in Terminal
iPhone 14 Simulator Running the Detox Tests

References

--

--

Rosen Toshev
Rosen Toshev

No responses yet