React Native Web Testing

React Native Web Testing

Today we will set up end to end tests for our React Native Web game!


See a professional software engineer at work. Unscripted. Mistakes included.

Project Repo: https://github.com/JesseRWeigel/battlemath

Part 1:


Part 2:


​Thanks for reading ❤

If you liked this post, share it with all of your programming buddies!

Follow me on Facebook | Twitter

Learn More

The Complete React Native and Redux Course

React Native - The Practical Guide

The complete React Native course ( 2nd edition )

React Native Web Full App Tutorial - Build a Workout App for iOS, Android, and Web

Build an iOS App with React Native and Publish it to the App Store

Using SVG in React Native

React Native Deep Linking for iOS and Android

How to use Detox to implement end-to-end testing in React Native App?

How to use Detox to implement end-to-end testing in React Native App?

In this tutorial, you learn how to use Detox to implement end-to-end testing in your React Native app. Detox is an end-to-end testing and automation framework that runs on a device or a simulator, just like an actual end user.

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.

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

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:

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.

React Native Tutorial : Build Dota 2 fan app in React Native

React Native Tutorial : Build Dota 2 fan app in React Native

In this video I'll talk about my approach on how I built a react native app, that I've been working on as a side project for ~1 week, for ios and android and what it can do.

In this video I'll talk about my approach on how I built a react native app, that I've been working on as a side project for ~1 week, for ios and android and what it can do.

The idea for the app was basically to help dota fans quickly browse vods of ongoing/previous tournaments, check upcoming matches, read news and play a little prediction mini game.

In the video I explain why I decided not to publish it.

I used a bunch of web scraping(cheerio) from my node backend to get the content needed for the app and a mongo database to store the data.

How to use React Test Renderer to test React components

How to use React Test Renderer to test React components

Test Driven Development (TDD) with React Test Renderer: Find out how to use React Test Renderer to test React components

It’s no secret that Enzyme has become the de facto standard for React components testing, but there are other good options around.

For example: React Test Renderer.

I personally like Test Renderer because of the way it works–it renders React components into pure JavaScript objects that are easy to use and understand.

Another advantage of React Test Renderer is that it is maintained by a core team at Facebook and is always up-to-date.

React Test Renderer has a great documentation, so I won’t duplicate it. Instead, I’d like to illustrate a few of the most common use cases in an example with a Test Driven Development (TDD) approach.

Setup

Test Renderer has a really easy setup process–just install the lib and you’re ready to go:

npm install --save-dev react-test-renderer
Testing with TDD

Ordinarily we’d need a component in order to start writing a test, but React Test Renderer enables us to write a test before the component is implemented.

Side Note: The reason for this is that TDD works like a charm when you test functions, so taking into account that most of the React components are pure functional components, TDD is applied really well here, especially with React Test Renderer. Sometimes it’s even faster to write your component starting with tests in case of complex logic because you need fewer iterations and debugging.

Let’s consider the requirements for a simple component:

  • It needs to have a class btn-group
  • It should be able to render its children
Testing className

First, we need to test the class of an empty component (as we follow TDD):

import React from "react";
  // [ 1 ] import the React Test Renderer
  import { create } from "react-test-renderer";

  const BtnGroup = () => null;
  
  test("the className of the component includes btn-group", () => {
    // [ 2 ] boilerplate code
    const root = create(<BtnGroup />).root;

    // [ 3 ] query for element
    const element = root.findByType("div");

    // [ 4 ] assert that className to include btn-group
    expect(element.props.className.includes("btn-group")).toBe(true);
  });

The test has 3 steps: test instance creation, element querying, and assertion.

Let’s skip over the more in-depth explanation of that for now and focus on fixing the test.
At first, it will break (as expected):

No instances found with node type: "undefined"

That means we need to add some node with some type. In our case, the type should be <div>:

const BtnGroup = () => <div />;

Once we change the code, the file watcher runs the test again and we receive an updated message:

expect(received).toEqual(expected) // deep equality

Expected: "btn-group"
Received: undefined

We’re already asserting. To pass the first test, all we need to do now is add a className prop.

const BtnGroup = () => <div className="btn-group" />;

After this change, we’ll see that rewarding green message:

As soon as the test is green we can slow down a bit and revisit the code of the test line by line. Here’s that code again:

import React from "react";
  // [ 1 ] import the React Test Renderer
  import { create } from "react-test-renderer";

  const BtnGroup = () => null;
  
  test("the className of the component includes btn-group", () => {
    // [ 2 ] boilerplate code
    const root = create(<BtnGroup />).root;

    // [ 3 ] query for element
    const element = root.findByType("div");

    // [ 4 ] assert that className to include btn-group
    expect(element.props.className.includes("btn-group")).toBe(true);
  });

[ 1 ] Test Renderer has only one way of creating component — the create method, so just import and use it.

[ 2 ] When creating a component, getting a test instance is a standard boilerplate code for React Test Renderer.

[ 3 ] There are 2 main ways to query for an element in Test Renderer: by type and by props. I prefer querying by type when there are no other containers around like in the current example. We’ll get to other methods a bit later.

[ 4 ] This assertion is pretty self-explanatory: just check that the ‘className’ prop value includes btn-group and you’re good to go.

Testing children

Let’s continue adding functionality to the BtnGroup component we already have since we know we need to meet the following requirement:

It should be able to render its children.

Testing the children prop is very straightforward. We just need to make sure that the passed value matches the result rendered:

import React from "react";
import { create } from "react-test-renderer";

const BtnGroup = () => <div className="btn-group" />;

test("renders BtnGroup component with children", () => {
  // [ 6 ] child text
  const text = "child";

  // boilerplate code, already mentioned in [ 2 - 3 ] above
  const instance = create(<BtnGroup>{text}</BtnGroup>).root;

  // query for element
  const element = instance.findByType("div");

  // assert child to match text passed
  expect(element.props.children).toEqual(text);
  });

[ 6 ] The value we pass to the component and the value we use to assert against it should be the same.

Since we’re using TDD, you might expect the test to break here. However, React supports passing children to components out of the box, so our test will be green.

If you’re wondering if the test is running successfully, you can print the element value with console.log.

The output is as follows:

Testing any props

Let’s continue adding requirements for our component:

should render any props passed.

Here’s a test:

import React from "react";
  import { create } from "react-test-renderer";

  // the component is still not updated as we use TDD
  const BtnGroup = () => <div className="btn-group" />;

  test("renders BtnGroup component with custom props", () => {
    // generate some custom props
    const props = { id: "awesome-button-id", className: "mb-3", children: "child" };

    // boilerplate code
    const instance = create(<BtnGroup {...props} />).root;
    
    // get element by component name
    const element = instance.findByType("div");

    // assert if an additional className was added to existing one
    expect(element.props.className).toEqual("btn-group mb-3");
    // assert "id" prop to match passed one
    expect(element.props.id).toEqual(props.id);
    // assert "children" to match passed
    expect(element.props.children).toEqual(children);
  });

The code of the test already looks familiar: we’re just checking that the prop values match passed.

Now, the test will break and issue the following message:

Expected: "btn-group mb-3"
Received: "btn-group"

What happens now is that we need to actually start passing props. Otherwise btn-group className will always be there:

const BtnGroup = props => <div className="btn-group" {...props} />;

Here’s where having tests comes in handy. We have another message telling us that the className case is specific:

Expected: "btn-group mb-3"
Received: "mb-3"

Now, the passed props replace the props that our component already has–in our case, btn-group is replaced with mb-3.

We should change the code of the component to fix this so that it handles className differently:

const BtnGroup = ({className = "", ...rest}) =>
    <div {...rest} className={`btn-group ${className}`} />;

The trick here is to de-structure props so that items needing special treatment have their name and all other props consolidated into a rest object.

Again, there is no special approach needed for the children prop, although they’re passed now as a regular prop instead of in the body of the component.

Now, the test should be green again. All of the previously written tests will also be green:

Note: I left a console.log here to show how you can check the output at any time.

As you can see, all of the assertions we’ve done — for now — are just checks that strings match.

But if there’s a need to check the number of items, we can use this handy method in Test Renderer: testInstance.findAllByType().

Let’s see how it works.

Testing the amount of items

To demonstrate how to count items in React Test Renderer, we should have some component that renders an array, or list. The requirement for it is something like this:

should render a list with correct items count.

To follow TDD, we’ll start with an empty functional component that renders an empty ul tag:

const ProductList = ({ list }) => <ul />;

Here’s a test we could write:

import React from "react";
  import { create } from "react-test-renderer";

  test("renders a list of items with correct items count", () => {
    // prepare the list for testing
    const list = [{ id: 1, text: "first item" }, { id: 2, text: "second item" }];

    // boilerplate code
    const root = create(<ProductList list={list} />).root;
    
    // [ 7 ] get list items
    const elementList = root.findAllByType("li");

    // assert if the length match with original list passed as a prop
    expect(elementList.length).toEqual(list.length);
  });

The goal of this test is to check if the number of rendered nodes equals the number of passed items.

Initially, the test will break with the following message:

To fix the test, we should render list items with li tags inside the container:

const ProductList = ({ list }) => <ul>
    {list.map(li => <li key={li.id}>{li.text}</li>)}
</ul>;

Now the test is green and we can talk about the code.

[ 7 ] To query specifically for nodes with type li, I use the testInstance.findAllByType() method that returns all elements with tag “li”.

There are also some other methods to search for multiple items: testInstance.findAll() and testInstance.findAllByProps().

The first one is useful when you need to check the overall amount, while the second one comes in handy when you want to count a specific prop, e.g., all nodes with a specific className.

Testing text

In most cases having a test for only items count is not sufficient, and you’ll also want to test the actual text a user can read.

There’s no specific functionality in React Test Renderer for that purpose, but that’s pretty easy to write if you consider that text can only be found in children.

import React from "react";
  import { create } from "react-test-renderer";

  test("renders all items with correct text", () => {
    // [ 8 ] prepare the list for testing
    const list = [{ id: 1, text: "first item" }, { id: 2, text: 33 }];

    // boilerplate code
    const root = create(<ProductList list={list} />).root;

    // get list items
    const elementList = root.findAllByType("li");

    // [ 10 ] Iterate over all items and search for text occurence in children
    elementList.forEach((el, index) => {
        // [ 11 ] convert text to string
        expect(el.children.includes(`${list[index].text}`)).toBe(true);
    });
  });

Having a list of all items in [ 8 ] we can iterate over the nodes of the component and make sure that every text was found [ 10 ].

This test is instantly green as soon as the component doesn’t have any filtering or sorting logic inside and just renders a list as it is, so we don’t have to change any lines of code in the test.

The only nit to add here is that rendered text is always a string regardless of the value type you pass [ 11 ].

Testing event handlers and hooks

Some of the functional components rely on more than just props and have their own state management thanks to the Hooks API.
Consider a classic example of a toggler component with the following requirements:

  • should render a button
  • should toggle children on button click

That means that children visibility should change on click.

Here’s an example of a test you could write:

import React from "react";
import { create } from "react-test-renderer";

// let component to be a fragment for start
const VisibilityToggler = () => <></>;

test("should toggle children nodes on button click", () => {
  const root = create(
    <VisibilityToggler>
      <div>awecome content</div>
    </VisibilityToggler>
  ).root;

  // helper to get nodes other than "button"
  const getChildrenCount = () =>
    root.findAll(node => node.type !== "button").length;

  // assert that button exists
  expect(root.findAllByType("button").length).toEqual(1);

  // query for a button
  const button = root.findAllByType("button")[0];

  // remember initial nodes count (before toggle)
  const initialCount = getChildrenCount();

  // trigger a hook by calling onClick of a button
  act(button.props.onClick);
  const countAfterFirstClick = getChildrenCount();

  // assert that nodes count after a click is greater than before
  expect(countAfterFirstClick > initialCount).toBe(true);

  // trigger another click
  act(button.props.onClick);
  const countAfterSecondClick = getChildrenCount();

  // check that nodes were toggled off and the count of rendered nodes match initial
  expect(countAfterSecondClick === initialCount).toBe(true);
});

The test looks huge, so let’s not try to fix it right away. First, let’s discuss the code a bit.

[ 12 ] Here is one new thing happens: act() method is used to wrap event handler calls.

Why should we? And how should we remember to do so? The second answer is easy: no need to remember, because React Test Renderer checks the code and prints a warning with a reason:

When writing UI tests, tasks like rendering, user events, or data fetching can be considered as “units” of interaction with a user interface.

React provides a helper called act() that makes sure all updates related to these “units” have been processed and applied to the DOM before you make any assertions ~ from the docs.

In other words, an act() method “awaits” for React updates and makes otherwise async code to look synchronous very similar to await from ES7.
At this stage, the test can’t find a button and breaks:

To resolve this issue, let’s add a button:

const VisibilityToggler = () => <><button /></>;

The button exists, but the onClick method is not found:

Don’t forget to add a button:

const VisibilityToggler = () => <><button /></>;

This is the next message you’ll receive after adding an onClick handler:

Finally, we’re at the point where we’re ready to add some state management with Hooks:

const VisibilityToggler = ({ children }) => {
  const [isVisible, setVisibility] = useState(false);
  const toggle = () => setVisibility(!isVisible);
  return (
    <>
      <button onClick={toggle}>toggle</button>
      {isVisible && children}
    </>
  );
};

Clicking on a button now toggles a state variable isVisible to the opposite value (true or false) that in return causes a render of “children” in case of “true” and skips rendering “children” in case of “false”.

All tests should be green now. You can find the complete source code for this example here:

Conclusion

Although React Test Renderer is usually associated with Snapshot testing, it can still be used to make specific assertions against your components with sufficient accuracy for most common use cases.

I personally like it because it has a clean API, it’s simple, and it’s easy to use along with TDD. I hope you like it too!