React Native End-to-end Testing and Automation with Detox

Detox is an end-to-end testing and automation framework that runs on a device or a simulator, just like an actual end user.

Software development demands fast responses to user and/or market needs. This fast development cycle can result (sooner or later) in parts of a project being broken, especially when the project grows so large. Developers get overwhelmed with all the technical complexities of the project, and even the business people start to find it hard to keep track of all scenarios the product caters for.

In this scenario, there’s a need for software to keep on top of the project and allow us to deploy with confidence. But why end-to-end testing? Aren’t unit testing and integration testing enough? And why bother with the complexity that comes with end-to-end testing?

First of all, the complexity issue has been tackled by most of the end-to-end frameworks, to the extent that some tools (whether free, paid or limited) allow us to record the test as a user, then replay it and generate the necessary code. Of course, that doesn’t cover the full range of scenarios that you’d be able to address programmatically, but it’s still a very handy feature.

Want to learn React Native from the ground up? This article is an extract from our Premium library. Get an entire collection of React Native books covering fundamentals, projects, tips and tools & more with SitePoint Premium. Join now for just $9/month.

End-to-end Integration and Unit Testing

End-to-end testing versus integration testing versus unit testing: I always find the word “versus” drives people to take camps — as if it’s a war between good and evil. That drives us to take camps instead of learning from each other and understanding the why instead of the how. The examples are countless: Angular versus React, React versus Angular versus Vue, and even more, React versus Angular versus Vue versus Svelte. Each camp trash talks the other.

jQuery made me a better developer by taking advantage of the facade pattern $('') to tame the wild DOM beast and keep my mind on the task at hand. Angular made me a better developer by taking advantage of componentizing the reusable parts into directives that can be composed (v1). React made me a better developer by taking advantage of functional programming, immutability, identity reference comparison, and the level of composability that I don’t find in other frameworks. Vue made me a better developer by taking advantage of reactive programming and the push model. I could go on and on, but I’m just trying to demonstrate the point that we need to concentrate more on the why: why this tool was created in the first place, what problems it solves, and whether there are other ways of solving the same problems.

As You Go Up, You Gain More Confidence

end-to-end testing graph that demonstrates the benefit of end-to-end testing and the confidence it brings

As you go more on the spectrum of simulating the user journey, you have to do more work to simulate the user interaction with the product. But on the other hand, you get the most confidence because you’re testing the real product that the user interacts with. So, you catch all the issues—whether it’s a styling issue that could cause a whole section or a whole interaction process to be invisible or non interactive, a content issue, a UI issue, an API issue, a server issue, or a database issue. You get all of this covered, which gives you the most confidence.

Why Detox?

We discussed the benefit of end-to-end testing to begin with and its value in providing the most confidence when deploying new features or fixing issues. But why Detox in particular? At the time of writing, it’s the most popular library for end-to-end testing in React Native and the one that has the most active community. On top of that, it’s the one React Native recommends in its documentation.

The Detox testing philosophy is “gray-box testing”. Gray-box testing is testing where the framework knows about the internals of the product it’s testing.In other words, it knows it’s in React Native and knows how to start up the application as a child of the Detox process and how to reload it if needed after each test. So each test result is independent of the others.

Prerequisites

  1. macOS High Sierra 10.13 or above
  2. Xcode 10.1 or above
  3. Homebrew:

     /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
    
  4. Node 8.3.0 or above:

     brew update && brew install node
    
  5. Apple Simulator Utilities: brew tap wix/brew and brew install applesimutils

  6. Detox CLI 10.0.7 or above:

     npm install -g detox-cli
    

See the Result in Action

First, let’s clone a very interesting open-source React Native project for the sake of learning, then add Detox to it:

git clone https://github.com/ahmedam55/movie-swiper-detox-testing.git
cd movie-swiper-detox-testing
npm install
react-native run-ios

Create an account on The Movie DB website to be able to test all the application scenarios. Then add your username and password in .env file with usernamePlaceholder and passwordPlaceholder respectively:

isTesting=true
username=usernamePlaceholder
password=passwordPlaceholder

After that, you can now run the tests:

detox test

Note that I had to fork this repo from the original one as there were a lot of breaking changes between detox-cli, detox, and the project libraries. Use the following steps as a basis for what to do:

  1. Migrate it completely to latest React Native project.
  2. Update all the libraries to fix issues faced by Detox when testing.
  3. Toggle animations and infinite timers if the environment is testing.
  4. Add the test suite package.

Setup for New Projects

Add Detox to Our Dependencies

Go to your project’s root directory and add Detox:

npm install detox --save-dev

Configure Detox

Open the package.json file and add the following right after the project name config. Be sure to replace movieSwiper in the iOS config with the name of your app. Here we’re telling Detox where to find the binary app and the command to build it. (This is optional. We can always execute react-native run-ios instead.) Also choose which type of simulator: ios.simulator, ios.none, android.emulator, or android.attached. And choose which device to test on:

{
  "name": "movie-swiper-detox-testing",

  // add these:
  "detox": {
    "configurations": {
      "ios.sim.debug": {
        "binaryPath": "ios/build/movieSwiper/Build/Products/Debug-iphonesimulator/movieSwiper.app",
        "build": "xcodebuild -project ios/movieSwiper.xcodeproj -scheme movieSwiper -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
        "type": "ios.simulator",
        "name": "iPhone 7 Plus"
      }
    }
  }
}

Here’s a breakdown of what the config above does:

  • Execute react-native run-ios to create the binary app.
  • Search for the binary app at the root of the project: find . -name "*.app".
  • Put the result in the build directory.

Before firing up the test suite, make sure the device name you specified is available (for example, iPhone 7). You can do that from the terminal by executing the following:

xcrun simctl list

Here’s what it looks like:

device-list

Now that weve added Detox to our project and told it which simulator to start the application with, we need a test runner to manage the assertions and the reporting—whether it’s on the terminal or otherwise.

Detox supports both Jest and Mocha. We’ll go with Jest, as it has bigger community and bigger feature set. In addition to that, it supports parallel test execution, which could be handy to speed up the end-to-end tests as they grow in number.

Adding Jest to Dev Dependencies

Execute the following to install Jest:

npm install jest jest-cli --save-dev

Generate Test Suite Files

To initialize Detox to use Jest, execute the following:

detox init -r jest

This will create an e2e folder at the root of the project and the following inside of it:

  • e2e/config.json contains the global config for the test runner:

      {
          "setupFilesAfterEnv": ["./init.js"],
          "testEnvironment": "node",
          "reporters": ["detox/runners/jest/streamlineReporter"],
          "verbose": true
      }
    
  • e2e/init.js contains the initialization code that runs before any of your tests are executed:

    const detox = require('detox');
      const config = require('../package.json').detox;
      const adapter = require('detox/runners/jest/adapter');
      const specReporter = require('detox/runners/jest/specReporter');
    
      // Set the default timeout
      jest.setTimeout(25000);
      jasmine.getEnv().addReporter(adapter);
    
      // This takes care of generating status logs on a per-spec basis. By default, jest only reports at file-level.
      // This is strictly optional.
      jasmine.getEnv().addReporter(specReporter);
    
      beforeAll(async () => {
        await detox.init(config);
      });
    
      beforeEach(async () => {
        await adapter.beforeEach();
      });
    
      afterAll(async () => {
        await adapter.afterAll();
        await detox.cleanup();
      });
    
  • e2e/firstTest.spec.js is the default Detox test file. This is where we will put all of the tests for the app. We’ll talk in details about the describe and it blocks, as well as the test suites that we’re going to create later.

Finally, We Run the Tests

To run the tests, navigate to your project’s root directory and execute the following:

detox test

Congratulations! We have everything ready for us to write our awesome tests. You can create and manage as many e2e/*spec.js files as you want and the test runner will execute them one by one. The spec file represents an independent set of features that you want to test. For example, checkout, guest checkout, user authentication, or sign up.

Inside the spec file you’ll have describe. This contains the smallest testing blocks—it block—which is created for reading. For example: it should reject creating an account if name already exits. And inside that it block, you add the assertions necessary to make sure that this is true. Ideally, we should reload React Native after each it block. This is as long as they don’t depend on each other. That prevents false positives, and makes debugging easier. Knowing that this test failed on clean slate, you don’t have to worry about all the other scenarios.

A Dive Deep into Our Test Suite

We’ll check the app caters for the following scenarios.

  • It should disallow logging in with wrong credentials. This one seems obvious, but it’s critical to the app workflow, so it needs to be tested with each change and/or deployment.
  • It should authenticate users with valid credentials—testing that the authentication functionality works properly.
  • It should kick out users when they sign out—testing whether signing out navigates users aways from the Browse, Explore, and Library screens.
  • It should allow guests to browse screen only. Users can log in or continue as guests, and in this case they would only be able to access the Browse screen and the features it has.
  • It should fetch movies that match the query—testing if the movies rendered are the ones that match the search query.
  • It should add to favorites—testing the add to favorite movies functionality, and making sure that the added movie appears in the favorite movies list.
  • It should add to watch list—similar to testing adding to favorite movies, but for watch-list functionality.
  • It should show all when more is clicked—testing the more button functionality of the Browse sections:
    • Trending Daily
    • Trending Weekly
    • Popular
    • Top Rated
    • Make sure it navigates to the movies list view with all movies that match the selected criteria.

Walking Through the Code of the Test Suite

Now it’s time for us to go over the code for testing the app. Before doing so, though, I recommend that you run the app on your device or simulator first. This is to familiarize yourself with the different screens and UI components within the app.

The first thing we need to do is define the functions we’ll be using to perform various tests. As I found myself matching the same set of UI elements and performing a specific set of actions, I’d abstract it to its own function, so I could reuse it in other tests and centralize fixes and changes in one place. Here are some examples on the abstraction I found helpful:

  • loginWithWrongCredentials()
  • loginWithRightCredentials()
  • goToLibrary()
  • signOut()
  • searchForMovie(title)

Detox’s API should easily make sense to you even if you haven’t used it previously. Here’s the code:

// e2e/firstTestSuite.spec.js

// fetch the username and password from the .env file
const username = process.env.username;
const password = process.env.password;

const sleep = duration =>
  new Promise(resolve => setTimeout(() => resolve(), duration)); // function for pausing the execution of the test. Mainly used for waiting for a specific UI component to appear on the screen

const loginWith = async (username, password) => {
  try {
    // click on login btn to navigate to the username, password screen
    const navigateToLoginBtn = await element(by.id("navigate-login-btn"));
    await navigateToLoginBtn.tap();

    const usernameInput = await element(by.id("username-input"));
    const passwordInput = await element(by.id("password-input"));

    await usernameInput.tap();
    await usernameInput.typeText(username);
    await passwordInput.typeText(password);

    const loginBtn = await element(by.id("login-btn"));

    await loginBtn.tap(); // to close the keyboard
    await loginBtn.tap(); // to start the authentication process

    const errorMessage = await element(
      by.text("Invalid username and/or password")
    );

    return { errorMessage, usernameInput, passwordInput };
  } catch (e) {
    console.log(
      "A sign out has not been done, which made the `navigate-login-btn` not found"
    );
  }
};

const loginWithWrongCredentials = async () =>
  await loginWith("alex339", "9sdfhsakjf"); // log in with some random incorrect credentials
const loginWithRightCredentials = async () =>
  await loginWith(username, password); // log in with the correct credentials

const goToLibrary = async () => {
  const libraryBtn = await element(by.id("navigation-btn-Library"));
  await libraryBtn.tap();
};

const goToExplore = async () => {
  const exploreBtn = await element(by.id("navigation-btn-Explore"));
  await exploreBtn.tap();
};

const signOut = async () => {
  await goToLibrary();

  const settingsBtn = await element(by.id("settings-btn"));
  await settingsBtn.tap();

  const signOutBtn = await element(by.id("sign-out-btn"));
  await signOutBtn.tap();
};

const continueAsGuest = async () => {
  const continueAsGuestBtn = await element(by.id("continue-as-guest"));
  await continueAsGuestBtn.tap();
};

const searchForMovie = async movieTitle => {
  const searchMoviesInput = await element(by.id("search-input-input"));
  await searchMoviesInput.tap();
  await searchMoviesInput.clearText();
  await searchMoviesInput.typeText(movieTitle);
};

const goBack = async () => {
  const goBackBtn = await element(by.id("go-back-btn"));
  goBackBtn.tap();
};

const goToWatchListMovies = async () => {
  const watchListBtn = await element(by.id("my-watchlist"));
  await watchListBtn.tap();
};

const goToFavoriteMovies = async () => {
  const favoriteMoviesBtn = await element(by.id("my-favorite-movies"));
  await favoriteMoviesBtn.tap();
};

const clickFavoriteButton = async () => {
  const addToWatchListBtn = await element(by.id("add-to-favorite-btn"));
  await addToWatchListBtn.tap();
};

const clickWatchListButton = async () => {
  const addToWatchListBtn = await element(by.id("add-to-watch-list-btn"));
  await addToWatchListBtn.tap();
};

const removeTestMoviesFromLists = async () => {
  try {
    await loginWithRightCredentials();
    await goToLibrary();
    await goToWatchListMovies();

    const movieItemInWatchList = await element(
      by.text("Crazy Rich Asians").withAncestor(by.id("watch-list"))
    );

    await movieItemInWatchList.tap();
    await clickWatchListButton();
    await goToLibrary();
    await goToFavoriteMovies();

    const movieItemInFavorites = await element(
      by.text("Avengers: Endgame").withAncestor(by.id("favorite-list"))
    );

    await movieItemInFavorites.tap();
    await clickFavoriteButton();
  } catch (e) {}
  await signOut();
};

// next: add function for asserting movie items

Next, we add the function for asserting the movie items. Unlike all the other functions we’ve defined above, this one is actually running an individual test—to assert that a specific movie item is visible on the screen:

const assertMovieItems = async (moviesTitles = []) => {
  for (let i = 0; i < moviesTitles.length; i++) {
    const moviesItem = await element(by.text(moviesTitles[i]));
    await expect(moviesItem).toBeVisible();
  }
};

// next: create the test suite

At this point, we’re now ready to create the test suite. This should be wrapped within a describe block. In order for each test to have a “clean” starting point, we use the following lifecycle methods:

  • beforeAll: executed once before this test suite runs. In this case, we call the removeTestMoviesFromLists() function. As you’ve seen earlier, this is the equivalent of a startup check sequence where the user logs in and visits various pages and clicks on the various buttons that will be used in the tests. This ensures that the app is in a minimum functional state before it starts running the tests.
  • beforeEach: executed before each test in this test suite runs. In this case, we want to reload React Native. Note that this has the same effect as pressing + r, rr, or Ctrl + r on your keyboard.
  • afterEach: executed after each test in this test suite runs. In this case, we want to sign the user out, which means that in each of our test, we need to log the user back in. Again, this is a good practice to get into when writing tests: each test has to have the same starting point. This ensures that they can run in any order and still produce the same results:
    describe("Project Test Suite", () => {
        beforeAll(async () => {
          await removeTestMoviesFromLists();
        });
    
        beforeEach(async () => {
          await device.reloadReactNative();
        });
    
        afterEach(async () => {
          try {
            await signOut();
          } catch (e) {}
        });
    
        // next: run the individual tests
      });
      

Now let’s walk through the individual tests. These can be defined inside an it block. Each it block starts from clean slate and asserts a specific, well-defined scenario (the ones we’ve covered in the previous section). Each test has a predictable output, which is what we need to assert:

it("should disallow login with wrong credentials", async () => {
  const {
    errorMessage,
    usernameInput,
    passwordInput
  } = await loginWithWrongCredentials();

  await expect(errorMessage).toBeVisible();
  await expect(usernameInput).toBeVisible();
  await expect(passwordInput).toBeVisible();
});

it("should login with right credentials", async () => {
  await loginWithRightCredentials();

  await goToLibrary();

  const watchListBtn = element(by.id("my-watchlist"));
  const favoriteMoviesBtn = element(by.id("my-favorite-movies"));

  await expect(watchListBtn).toBeVisible();
  await expect(favoriteMoviesBtn).toBeVisible();
});

it("should kick user out when sign out is clicked", async () => {
  await loginWithRightCredentials();
  await goToLibrary();
  await signOut();

  const loginBtn = await element(by.id("navigate-login-btn"));
  await expect(loginBtn).toBeVisible();
});

it("should allow guest in for Browse only", async () => {
  await continueAsGuest();
  await goToLibrary();

  const watchListBtn = element(by.id("my-watchlist"));
  const favoriteMoviesBtn = element(by.id("my-favorite-movies"));

  await expect(watchListBtn).toBeNotVisible();
  await expect(favoriteMoviesBtn).toBeNotVisible();

  await goToExplore();

  const moviesSwipingView = element(by.id("movies-swiping-view"));

  await expect(moviesSwipingView).toBeNotVisible();
});

it("should fetch and render the searches properly", async () => {
  await loginWithRightCredentials();

  const searches = [
    {
      query: "xmen",
      results: ["X-Men: Apocalypse", "X-Men: Days of Future Past"]
    },
    {
      query: "avengers",
      results: ["Avengers: Endgame", "Avengers: Age of Ultron"]
    },
    { query: "wolverine", results: ["Logan", "The Wolverine"] }
  ];

  for (let i = 0; i < searches.length; i++) {
    const currentSearch = searches[i];

    await searchForMovie(currentSearch.query);
    await assertMovieItems(currentSearch.results);
  }
});

it("should add to favorite", async () => {
  await loginWithRightCredentials();

  await searchForMovie("avengers");
  await element(by.text("Avengers: Endgame")).tap();

  await clickFavoriteButton();
  await goBack();
  await goToLibrary();
  await goToFavoriteMovies();

  await sleep(3000);

  var movieItemInFavorites = await element(
    by.id("favorite-list").withDescendant(by.text("Avengers: Endgame"))
  );

  await expect(movieItemInFavorites).toBeVisible();
});

it("should add to watchlist", async () => {
  await loginWithRightCredentials();

  await searchForMovie("crazy rich");
  await element(by.text("Crazy Rich Asians")).tap();

  await clickWatchListButton();

  await goBack();
  await goToLibrary();
  await goToWatchListMovies();

  await sleep(3000);

  const movieItemInFavorites = await element(
    by.id("watch-list").withDescendant(by.text("Crazy Rich Asians"))
  );

  await expect(movieItemInFavorites).toBeVisible();
});

it("should show all lists more is clicked", async () => {
  await loginWithRightCredentials();

  const trendingDailyMoreBtn = await element(by.id("trending-daily-more"));
  await trendingDailyMoreBtn.tap();

  await goBack();
  await sleep(300);

  const trendingWeeklyMoreBtn = await element(by.id("trending-weekly-more"));
  await trendingWeeklyMoreBtn.tap();

  await goBack();
  await sleep(300);

  const popularMoreBtn = await element(by.id("popular-more"));
  await popularMoreBtn.tap();

  await goBack();
  await sleep(300);

  const browseSectionsView = await element(by.id("browse-sections-view"));
  await browseSectionsView.scrollTo("bottom");

  const topRatedMoreBtn = await element(by.id("top-rated-more"));
  await topRatedMoreBtn.tap();
});

From the code above, you can see that the workflow for each test can be summarized in four steps:

  1. Initialize the state. This is where we log in the user so each test have the same starting point.
  2. Select the UI component. This is where we use matchers to target specific UI components.
  3. Trigger the action. This is where we trigger the action on the UI component that we selected.
  4. Assert that the expected output exists or doesn’t exist. This is where we use the expect() method to test whether the action has triggered another UI component to be shown or hidden from the screen. If the assertion returns true, the test passed.

Note: because of the constant changing nature of the app, the movie items that we’re asserting can change very frequently. If you’re reading this some time after this piece was published, be sure to manually verify first if specific items are visible in the screen. This helps avoid the test from failing unnecessarily and will save you headaches in getting the demo to work.

Matchers

You can match or select any UI element by ID, text, label, parent, child (at any level), or traits. Here are a couple of examples:

const usernameInput = await element(by.id("username-input"));
const errorMessage = await element(by.text("Invalid username and/or password"));

Actions To Be Performed

Detox can perform a huge set of actions on UI elements: tap, longPress, multiTap, tapAtPoint, swipe, typeText, clearText, scroll, scrollTo, and others.

Here are a few examples:

await usernameInput.tap();

await usernameInput.typeText(username);

await passwordInput.clearText();

const browseSectionsView = await element(by.id("browse-sections-view"));

await browseSectionsView.scrollTo("bottom");

Assertions to Test

Detox has a set of assertions that can be performed against matched UI elements: toBeVisible, toNotBeVisible, toExist, toNotExist, toHaveText, toHaveLabel, toHaveId, toHaveValue. Here are couple of examples:

const assertMovieItems = async (moviesTitles = []) => {
  for (let i = 0; i < moviesTitles.length; i++) {
    const moviesItem = await element(by.text(moviesTitles[i]));
    await expect(moviesItem).toBeVisible();
  }
};

await assertMovieItems(["Avengers: Endgame", "Avengers: Age of Ultron"]);
const watchListBtn = element(by.id("my-watchlist"));
await expect(watchListBtn).toBeNotVisible();

Challenges and Recipes

Endless Looping Animations or Timers

One of the issues I have faced is that Detox halts if there is a timer looping or animation that never ends. I had to do the following to debug such issues:

  1. Search and debug parts in the app tree and imports by modifying and eliminating them.
  2. Run the test suite again to check whether the issue persists.
  3. After that and most of the time, the issue is an animation that starts itself right after it finishes. So I imported react-native-config, which is a very handy tool to set some environment variables for toggling some behaviors or features depending on the environment. In my case, it was adding isTesting=true in the .env file, checking for it in the codebase and disabling the animation loop or making the duration a lot less, so it speeds up the test suite.

As you can see, it’s mostly a matter of playing around with the animation settings in your app. For more information on troubleshooting Detox, you can check out the following documentation:

Adding TestID to the Proper UI Element

Another challenge is digging down the component to pass the testID to, as Detox doesn’t support it for custom components. Sometimes you’d need to wrap the component with a built-in component—such as the View component—in order to match and then interact with it. This is especially true if the code of inner built-in component is an imported library inside the node_modules folder.

Compose TestID with Contextual Data

Another scenario that I had to handle is components that are being rendered in multiple places with different event handlers and titles. So, I had to create a composite testID with the title, lowered case and hyphened, and the testID identifier for the component.

For example, the more button of all browse sections: as it’s the same component being rendered for each one of them:

 const testID = `${(this.props.title||'').toLowerCase().replace(/\s/g, '-')}-more`

 return (
  ...
    <AppButton
       onlyText
       style={styles.moreButton}
       textStyle={styles.moreButtonText}
       onPress={this.onMorePress}
       testID={testID}
    >
       MORE
    </AppButton>
 }

Sometimes, it’s not a single prop, but rather children, so you’d end up filtering them and mapping them to get the text node and its value.

Narrowing Down Selectors

As some navigators tend to keep the previous screens in the tree, Detox would find two items with the same identifier (text, ID, label) and throw an exception. Thus, we need to filter out the items from a specific screen to get what we need. You can do that by using the withAncestor() matcher, which matches by a specific ancestor ID:

const movieItemInWatchList = await element(
  by.text("Crazy Rich Asians").withAncestor(by.id("watch-list"))
);

await movieItemInWatchList.tap();

Let’s See the Result in a More Engaging Way

You can check out a screen recording of the tests running below. You should get similar results when you run the tests for the app.

To simulate text typing, the keyboard has to appear when an input is selected. To enable that, go to Simulator > Keyboard > Toggle Software Keyboard. You should do this step before start running the tests.

Conclusion

In this tutorial, you learned how to use Detox to implement end-to-end testing in your React Native app.

Specifically, you learned how to add the Detox configuration for running the tests on iOS, write selectors for interacting with the UI components, and asserting that specific content exists in screen after interacting with the UI. Lastly, you learned some of the most common challenges that you might encounter and how to solve them.

We’ve only tested for iOS in this tutorial, but you should be able to run the tests on Android as well. Do note that you may have to downgrade your app to a lower version of React Native and Detox in order for it to work on Android. This is because iOS support is better in Detox.

You can view the source code on this GitHub repo.