Emanuel Suriano Hi 👋 I build stuff with JavaScript 💻 Once a month I write an article ✍️ Sometimes I give talks 💬

End-to-end testing in React Native with Detox

8 min read 2399

End-To-End Testing In React Native With Detox

End-to-end testing is a technique that is widely performed in the web ecosystem with frameworks like Cypress, Puppeteer, or maybe with your own custom implementation.

But when it comes to the mobile world, this practice is not that common, and there are several existing solutions to address. I have a theory that most mobile developers think testing mobile application is hard and requires a lot of setup and configuration, and therefore they just skip it.

The goal of this article is to explain how to implement the end-to-end testing framework Detox in a React Native application, write a bunch of interaction tests, and, finally, integrate it into your development workflow.

⚠️ Disclaimer ️️⚠️: Given the length of this article, I will only focus on the iOS flow. Nevertheless, I plan to release a second part covering Android, too.

A quick introduction to end-to-end testing 📖

Let’s start with a definition of end-to-end from Software Testing Dictionary:

End-to-end testing is a technique used to test whether the flow of an application right from start to finish is behaving as expected. The purpose of performing end-to-end testing is to identify system dependencies and to ensure that data integrity is maintained between various system components and systems.

In contrast to unit testing, end-to-end testing tries to cover as much of your application’s functionality as it can. The more it covers, the more reliable your tests will be. Therefore, it includes all the stages of an application:

  • Set up environment
  • Installation of the application (if it’s necessary)
  • Initialization
  • Executing routines
  • Expecting events or behaviors to happen

This is how end-to-end testing looks in the browser using Cypress:

End-To-End Testing On Web With Cypress

Cypress is able to create an instance of Chrome, run a URL, and then start interacting with the webpage by selecting elements (div, button, input) using native selectors (getElementById, getElementByName, getElementByClassName), and then triggering events (click, change, focus).

At any point in the tests, the developer can assert/expect something to happen or to have a specific value. In case all the expectations were true, the result of the test suite will be successful.

End-to-end testing in mobile 🤯

The process of testing mobile applications is actually quite similar to the web. Let’s go thought the previously described steps:

  • Set up environment: create an instance of an emulator (Android/iOS device)
  • Installation: install the application
  • Initialization: run the application
  • Executing routines: Depending on the framework this may change, but all of them are using native directives to get the reference of an element (Button, View, TextInput) and then executing actions (press, type, focus)
  • Expecting events: Using the same functions described before, they can assert/expect values or events that happened

This is how end-to-end testing looks like in mobile using Detox:

What is Detox, and why should you pick it? ⭐️

Detox is an end-to-end framework for mobile apps developed by Wix, one of the top contributors inside the React Native community. They also maintain amazing projects such as react-native-navigation, react-native-ui-lib, and, of course, Detox.

What I like about this framework is the great abstraction it provides to select and trigger actions on elements. This is how a normal test looks:

describe('Login flow', () => {
  it('should login successfully', async () => {
    await device.reloadReactNative();
    // getting the reference of an element by ID and expecting to be visible
    await expect(element(by.id('email'))).toBeVisible();

    // Getting the reference and typing
    await element(by.id('email')).typeText('john@example.com');
    await element(by.id('password')).typeText('123456');

    // Getting the reference and executing a tap/press
    await element(by.text('Login')).tap();

    await expect(element(by.text('Welcome'))).toBeVisible();
    await expect(element(by.id('email'))).toNotExist();
  });
});

As you can see, the syntax is quite readable, and by using async/await, you can write synchronous and easy-to-understand tests. Let’s jump into the demo!

Ready, set, code! 🏎

In case you want to skip the explanation and check the code, I’ll give you the link for the repository with the project already bootstrapped and the tests in place.

As the focus of this article is testing and not explaining how to set up React Native, I suggest bootstrapping your project using react-native init, which creates a pretty simple and clean React Native project.

Start by installing the dependency and creating the fresh new project.

~ npm install react-native -g
~ react-native init testReactNativeDetox

               ######                ######
             ###     ####        ####     ###
            ##          ###    ###          ##
            ##             ####             ##
            ##             ####             ##
            ##           ##    ##           ##
            ##         ###      ###         ##
             ##  ########################  ##
          ######    ###            ###    ######
      ###     ##    ##              ##    ##     ###
   ###         ## ###      ####      ### ##         ###
  ##           ####      ########      ####           ##
 ##             ###     ##########     ###             ##
  ##           ####      ########      ####           ##
   ###         ## ###      ####      ### ##         ###
      ###     ##    ##              ##    ##     ###
          ######    ###            ###    ######
             ##  ########################  ##
            ##         ###      ###         ##
            ##           ##    ##           ##
            ##             ####             ##
            ##             ####             ##
            ##          ###    ###          ##
             ###     ####        ####     ###
               ######                ######


                  Welcome to React Native!
                 Learn Once Write Anywhere

✔ Downloading template
✔ Copying template
✔ Processing template
✔ Installing dependencies
✔ Installing CocoaPods dependencies (this may take a few minutes)

  Run instructions for iOS:
    • cd testReactNativeDetox && react-native run-ios
    - or -
    • Open testReactNativeDetox/ios/testReactNativeDetox.xcworkspace in Xcode or run "xed -b ios"
    • Hit the Run button

  Run instructions for Android:
    • Have an Android emulator running (quickest way to get started), or a device connected.
    • cd testReactNativeDetox && react-native run-android

After this step, you can try running the application in the emulator by executing:

~ cd testReactNativeDetox
~ react-native run-ios

React Native Bootstrapped Application

Time to test! 🔧

Before jumping into testing, you need to have the following prerequisites:

  • Xcode installed
  • Homebrew installed and updated
  • Node.js installed (brew update && brew install node)
  • applesimutils installed (brew tap wix/brew; brew install applesimutils;)
  • detox-cli installed (npm install -g detox-cli)

Start by adding Detox as a dev dependency for the project.

~ yarn add detox -D

Inside the CLI, they provide a command that can automatically set up the project. You need to run:

~  detox init -r jest

detox[34202] INFO:  [init.js] Created a file at path: e2e/config.json
detox[34202] INFO:  [init.js] Created a file at path: e2e/init.js
detox[34202] INFO:  [init.js] Created a file at path: e2e/firstTest.spec.js
detox[34202] INFO:  [init.js] Patching package.json at path: /Users/USERNAME/Git/testReactNativeDetox/package.json
detox[34202] INFO:  [init.js]   json["detox"]["test-runner"] = "jest";

This will create a new folder called e2e with a basic test and some initial configuration such as init.js, which is the file that tells jest to start the simulator and so on. Let’s modify this initial test to check if the two first sections are visible.

describe('Example', () => {
  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('should have "Step One" section', async () => {
    await expect(element(by.text('Step One'))).toBeVisible();
  });

  it('should have "See Your Changes" section', async () => {
    await expect(element(by.text('See Your Changes'))).toBeVisible();
  });
});

Next, you need to add a configuration for Detox inside your package.json. Add the following object to the detox key, replacing the name of testReactNativeDetox with the name of your application:

{
  "detox": {
    "test-runner": "jest",
    "configurations": {
      "ios.release": {
        "binaryPath": "./ios/build/Build/Products/Release-iphonesimulator/testReactNativeDetox.app",
        "build": "xcodebuild -workspace ios/testReactNativeDetox.xcworkspace -configuration release -scheme testReactNativeDetox -sdk iphonesimulator -derivedDataPath ios/build",
        "type": "ios.simulator",
        "name": "iPhone X"
      }
    }
  }
}

Once done, try to build the application by running:

~ detox build

In case your build failed with the message clang: error: linker command failed with exit code 1 (use -v to see invocation), please refer to this solution in GitHub issues and try running the command again.

Finally, time to run the test!

~ detox test

 PASS  e2e/firstTest.spec.js (7.514s)
  Example
    ✓ should have "Step One" section (260ms)
    ✓ should have "See Your Changes" section (278ms)

Running Initial Detox Test

Time to make it fancier! 💅

Let’s put those boring and flat sections inside a colorful carousel! Because who doesn’t love them?

[CAROUSEL IMAGE]

In order to save time, I decided to use an existing carousel component built by the community. For this demo, I used react-swipeable-views-native. I’m sure there must be better alternatives out there, but this one was the perfect match for my needs.

Also, in order to generate nice random colors, I used randomColor.

Install both libraries as dependencies for the project:

~ yarn add react-swipeable-views-native randomcolor

Then I made a few modifications inside App.js — you can find the code here. This is the summary of changes:

  • Wrap all the sections inside SwipeableViews to enable swiping behavior
  • Wrap each section inside a custom View called Slide that implements properties like padding and backgroundColor
  • Add a Button and a TextInput component to the last two slides

And this is the result:

Demo React Native App With Carousel

Writing Detox tests 🧪

In order to make things easier, let’s add two new scripts into the package.json:

{
  "scripts": {
    "e2e:test": "detox test -c ios.release",
    "e2e:build": "detox build -c ios.release"
  }
}

Now that the application has changed, you need to create a new build of it in order to run tests with the modified version. Execute the following command:

~ yarn e2e:build

This process may take some time. In the meantime, let’s take a quick look at the existing tests:

describe('Example', () => {
  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('should show "Step One"', async () => {
    await expect(element(by.text('Step One'))).toBeVisible();
  });

  it('should show "See Your Changes"', async () => {
    await expect(element(by.text('See Your Changes'))).toBeVisible(); // THIS TEST WILL FAIL!
  });
});

The second test will definitely fail because the “See Your Changes” section is now in the second slide of the Carousel, which is not visible for the user until they swipe. Therefore, let’s make Detox move to that slide!

describe('Example', () => {
  // previous tests here

  it('should render "See Your Changes" in the second slide', async () => {
    // getting the reference of the slides and make a swipe
    await element(by.id('slides')).swipe('left');
    await expect(element(by.text('See Your Changes'))).toBeVisible(); // no this will pass!
  });
});

At this point, you can execute the end-to-end tests, and they should pass! The command is:

~ yarn e2e:test

 PASS  e2e/firstTest.spec.js (7.514s)
  Example
    ✓ should have "Step One" section (260ms)
    ✓ should render "See Your Changes" in the second slide (993ms)

Running A Basic End-To-End Test With Detox

Let’s add a few more tests to cover the following scenarios:

  • Test that the carousel allows the user to move back and forth inside the slides.
  • Move the third slide and interact with the Button
  • Move the last slice and interact with the TextInput
describe('Example', () => {
  // previous tests here

  it('should enable swiping back and forth', async () => {
    await expect(element(by.text('Step One'))).toBeVisible();
    await element(by.id('slides')).swipe('left');
    await element(by.id('slides')).swipe('right');
    await expect(element(by.text('Step One'))).toBeVisible();
  });

  it('should render "Debug" and have a Button to click in the third slide', async () => {
    await element(by.id('slides')).swipe('left');
    await element(by.id('slides')).swipe('left');
    await expect(element(by.text('Debug'))).toBeVisible();

    await element(by.text('Click here!')).tap();
    await expect(element(by.text('Clicked!'))).toBeVisible();
  });

  it('should render "Learn More" and change text in the fourth slide', async () => {
    await element(by.id('slides')).swipe('left');
    await element(by.id('slides')).swipe('left');
    await element(by.id('slides')).swipe('left');
    await expect(element(by.text('Learn More'))).toBeVisible();

    const docsInput = element(by.id('docsInput'));

    await expect(docsInput).toBeVisible();

    await docsInput.clearText();
    await docsInput.typeText('Maybe later!');

    await expect(docsInput).toHaveText('Maybe later!');
  });
});

Feature fully tested! Let’s run the tests again.

~ yarn e2e:test

 PASS  e2e/firstTest.spec.js (22.128s)
  Example
    ✓ should have "Step One" section (268ms)
    ✓ should render "See Your Changes" in the second slide (982ms)
    ✓ should enable swiping back and forth (1861ms)
    ✓ should render "Debug" and have a Button to click in the third slide (2710ms)
    ✓ should render "Learn More" and change text in the fourth slide (9964ms)

Running The Full End-To-End Test With Detox

Bonus: Running E2E test in CI 🎁

Running tests inside CI is quite important; they basically eliminate the need to do manual testing and prevent shipping bugs to production (in case we have the proper set of tests). For this example, I decided to use TravisCI because it has an amazing integration with GitHub and also provides an unlimited plan for open source projects.

In case you are using GitHub, you can install the Travis Application, create a new plan, and allow it to access your repositories.

After that, you need to create a new file inside your project called .travis.yml, which defines the steps you want to run in CI.

I tweaked the CI configuration inside the Official Documentation of Detox a little bit, and this is the one that works in my case.

language: objective-c
osx_image: xcode10.2

branches:
  only:
    - master

env:
  global:
    - NODE_VERSION=stable

install:
  - brew tap wix/brew
  - brew install applesimutils
  - curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.2/install.sh | bash
  - export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
  - nvm install $NODE_VERSION
  - nvm use $NODE_VERSION
  - nvm alias default $NODE_VERSION

  - npm install -g react-native-cli
  - npm install -g detox-cli
  - npm install
  - cd ios; pod install; cd -;

script:
  - npm run e2e:ci

One last thing: add the command e2e:ci into your package.json. This command will build the application (detox build), run the tests (detox test), and close the emulator in order to finish the execution (--cleanup flag).

{
  "scripts": {
    "e2e:test": "detox test -c ios.release",
    "e2e:build": "detox build -c ios.release",
    "e2e:ci": "npm run e2e:build && npm run e2e:test -- --cleanup"
  }
}

Once you pushed all the changes into your master branch, try to open a new pull request. You should see a new pull request checker has been added, which will call Travis, and this one runs the Detox tests.

End-To-End PR Checker

Here is the link to the full log in Travis for that pull request.

Closing words

In case you were thinking of adding tests to your React Native application, I highly encourage you to go and try Detox! Detox is an amazing end-to-end testing solution for mobile, and after using it for quite some time, these are the pros and cons:

  • ✅ Very well abstracted syntax for matchers and to trigger specific actions
  • ✅ Integration with Jest is just wonderful
  • ✅ Possibility to run tests in CI
  • ❌ Sometimes you might encounter configuration errors, and finding the proper solution may take some time. The best way to address this problem is to go and take a deep look at the GitHub Issues

Let’s keep building stuff together! 👷

References and further reading

plug

Plug: , a DVR for web apps

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Emanuel Suriano Hi 👋 I build stuff with JavaScript 💻 Once a month I write an article ✍️ Sometimes I give talks 💬

Leave a Reply