12

Automated Testing for React Native Apps

Automating tests is one of the most important things you must do when your project grows. It can help ensure a certain level of quality of your application and can enable you to run faster release cycles without introducing bugs in every release. I recommend writing automated tests for your application as soon as possible.

It is much easier to start writing tests right from the beginning because then, you are forced to structure your code in a way that works for automated testing. It can be hard to refactor an application to use automated testing when this wasn’t in focus at the beginning.

In this chapter, you will learn about automated testing in general and how to use automated testing in React Native apps. You will learn about the different tools and frameworks for different types of automated testing. These tools and frameworks are used in production by some of the most widely used apps in the world, so I recommend using them.

To give you a good overview of all these topics, this chapter will cover the following topics. If you are already familiar with automated testing in general, you can skip the first section:

  • Understanding automated testing
  • Working with unit and integration tests in React Native
  • Working with component tests
  • Understanding end-to-end tests

Technical requirements

To be able to run the code in this chapter, you must set up the following:

  • A working React Native environment (bit.ly/prn-setup-rn – React Native CLI Quickstart).
  • While most of this chapter should also work on Windows, I recommend working on a Mac. You need to work on a Mac to run Detox end-to-end tests on iOS simulators.
  • An AWS account for accessing AWS Device Farm.

Understanding automated testing

There are different forms of automated testing. The following forms of automated testing are the most common ones and will be covered in this chapter:

  • Unit tests: Unit tests cover the smallest parts of your business logic, such as single functions.
  • Integration tests: This form of testing works very similar to unit tests in React Native, but it covers multiple pieces of your business logic and tests whether the integration of these parts works as expected.
  • Component tests: These tests cover your React Native UI components and check whether they do what they are expected to do. You can also check for (unexpected) changes in your components with this form of testing.
  • End-to-end tests: This form of testing simulates end user behavior and checks whether your whole application behaves like it is expected to do.

To get the most out of automated testing, you should implement all four types of tests. All of them cover different areas of your application and can help you find different types of errors that the other types of testing can’t find.

When working with automated testing, you should try to have high code coverage. Code coverage describes the percentage of your code that is covered by your automated tests. While it is a good metric to get an idea of whether automated tests are used in a project and that you didn’t forget any parts of your application, it has little significance on its own.

This is because it doesn’t help to write one test for every line of code you have. When working with automated tests, especially unit tests, integration tests, and component tests, you should always write multiple tests for the part you want to test, covering the most common use cases as well as important edge cases. This means you have to think a lot before writing your tests.

With unit tests, integration tests, and component tests, you typically test small parts of your application. This also means you have to create an environment where these small parts can work on their own. This can be achieved by mocking dependencies that are used in the tested part.

Mocking means writing your own implementation of a dependency for the testing environment, to ensure it behaves as expected and to rule out that an error in the dependency leads to an error in the test.

Note

It’s not always clear which parts of an application should be mocked in a test. I would recommend mocking more rather than less in unit tests because you want to test whether a very small part of your code behaves as it is expected to. In integration and component tests, I recommend mocking less rather than more because you want to test larger parts of your application and see whether the whole combination works.

Because unit tests, integration tests, and component tests run in a test environment and use only parts of your application, they are very reliable. There aren’t many things that can interfere with these tests to distort the test results. This is different compared to working with end-to-end tests.

These tests run on your real application on a simulator or real device and depend on things such as network connectivity or other device behavior. This can lead to test flakiness. A flaky test is a test that passes and fails on different test runs without any code changes.

This is a real problem because it results in you having to manually check whether the test fails only because it is flaky or because it found a bug in your application. We’ll cover test flakiness in more detail in the Understanding end-to-end tests section.

But first, we’ll start by testing the business logic parts of our application automatically by using unit and integration tests.

Working with unit and integration tests in React Native

When you start a new React Native project, it comes with a testing framework called Jest preconfigured. This is the recommended framework for unit tests, integration tests, and component tests. We’ll use it in the following sections.

Let’s start with unit testing. We’ll use our example project again, but we will go back a few commits to use the local movie service implementation. You can have a look at the complete code by selecting the chapter-12-unit-testing branch in the example repository.

This local service implementation is very suitable as an example for unit testing because it has no dependencies. We know the data it is working on and can write tests very easily. In this example, we’ll test two API calls: getMovies and getMovieById.

The following code shows our first unit tests:

import {getMovies,getMovieById} from '../src/services/movieService';
describe('testing getMovies API', () => {
  test('getMovies returns values', () => {
    expect(getMovies()).toBeTruthy();
  });
  test('getMovies returns an array', () => {
    expect(getMovies()).toBeInstanceOf(Array);
  });
  test('getMovies returns three results', () => {
    expect(getMovies()).toHaveLength(46);
  });
});
describe('testing getMovieById API', () => {
  test('getMovies returns movie if id exists', () => {
    expect(getMovieById(892153)).toBeTruthy();
  });
  test('getMovies returns movie with correct information,
  () => {
    const movie = getMovieById(892153);
    expect(movie?.title).toBe('Tom and Jerry Cowboy Up!');
    expect(movie?.release_date).toBe('2022-01-24');
  });
  test('getMovies returns nothing if id does not exist', ()
  => {
    expect(getMovieById(0)).toBeFalsy();
  });
});

The preceding code contains six tests grouped into two sections. The first section contains all tests regarding the getMovies API call. With the first test, we ensure that the getMovies call returns a value. The second test checks whether getMovies returns an array, while the last test validates that the returned array has the length we expect.

Note

You might be wondering why we need three tests here when the last one fails as soon as one of the first two fails. This is because it gives us useful information so that we can see which tests fail. This makes debugging and searching for changes or bugs a lot easier.

In the second section of the code example, we test the getMoviesById block. Again, we have three tests. The first one verifies that the API call returns a value for a movie ID we know exists. The second test checks that the correct movie is returned. The third test ensures that the getMovieById API call does not return anything for an ID we know doesn’t exist.

As you can see, you shouldn’t only write one unit test when testing a function; you should try to cover at least the following areas:

  • Check for existing and non-existing return values
  • Check for expected data types
  • Check whether the returned values match your expected data
  • If you work with ranges, write tests for the edge cases
  • If you experienced a bug, reproduce it with a unit test to ensure it will never be encountered again

Writing integration tests with Jest work pretty much the same as unit tests. The difference is that you test larger parts of your application. While the terminology is not always consistent, you can find a good definition in the React Native documentation (https://bit.ly/prn-integration-tests). It counts as an integration test when at least one of the following four points is true:

  • Combines several modules of your app
  • Uses an external system
  • Makes a network call to other applications (such as the weather service API)
  • Does any kind of file or database I/O

One thing that is quite important when working with integration tests is mocking. When running tests using Jest as your test runner, you don’t have any native parts of your application available; your tests run your JavaScript code in a JavaScript-only environment.

This means you have to mock at least all native parts of your application. Jest comes with advanced support for mocking different parts of your code. You can check out the detailed documentation here: https://bit.ly/prn-jest-mocking.

While unit and integration testing work pretty much similar to tests on server applications or applications written in other languages, component tests are a frontend-only test type. This is what we’ll look at next.

Working with component tests

When working with component tests in React Native, the recommended solution is to use react-native-testing-library. This library is compatible with Jest, adds a rendering environment for your JavaScript application, and provides multiple useful selectors and other functions.

The easiest type of component test is to check for (unexpected) changes. This is called snapshot testing. The component will be rendered and transformed into an XML or JSON representation, called a snapshot. This snapshot is stored with the tests. The next time the test runs, it is used to check for changes.

The following code example shows a snapshot test for the HomeView component of our example application:

import React from 'react';
import HomeView from '../src/views/home/Home.view';
import {render} from '@testing-library/react-native';
const genres = require('../assets/data/genres.json');
describe('testing HomeView', () => {
  test('HomeView has not changed', () => {
    const view = render(
      <HomeView genres={genres}
                name={'John'}
                 onGenrePress={()=>{}}/>,
    );
    expect(view).toMatchSnapshot();
  });
});

This code example shows how important it is to take testing into account when structuring your code. We can simply import the HomeView component from Home.view and pass properties to it when rendering it.

We don’t have to mock any stores or external dependencies. This makes it very easy to create the first snapshot test. We use the render function from react-native-testing-library to create a snapshot representation of the component. Then, we expect it to match our stored snapshot.

While snapshot testing can be very useful to realize unexpected changes, it only gives us information if anything has changed. To get more information about what changed and check whether everything works as expected, we have to create more advanced component tests.

The following code example shows how we can check whether the component renders valid content:

  test('all list items exist', () => {
    render(<HomeView genres={genres}
                     name={'John'}
                     onGenrePress={() => {}} />);
    expect(screen.getByText('Action')).toBeTruthy();
    expect(screen.getByText('Adventure')).toBeTruthy();
    expect(screen.getByText('Animation')).toBeTruthy();
  });

In this test, we pass all three genres we have in our genres.json file to the HomeView component. Again, we render it using the render function from react-native-testing-library. After rendering, we use another function from the testing library called screen.

With this function, we can query values that are rendered to the simulated screen. This is how we try to find the titles of our three genres, which we expect to be there by checking for them with toBeTruthy.

Next, we’ll go one step further and check whether we can click on the list items:

  test('all list items are clickable', () => {
    const mockFn = jest.fn();
    render(<HomeView genres={genres}
                     name={'John'}
                     onGenrePress={mockFn} />);
    fireEvent.press(screen.getByText('Action'));
    fireEvent.press(screen.getByText('Adventure'));
    fireEvent.press(screen.getByText('Animation'));
    expect(mockFn).toBeCalledTimes(3);
  });

In this test, we use the fireEvent function from react-native-testing-library to create a press event on every list item. To be able to check whether the press event triggers our onGenrePress function, we pass a Jest mock function, created with jest.fn(), to the component.

This mock function collects a lot of information during the test, including how often it was called during the test. This is what we check for in this test. However, we can go one step further.

Not only can we check whether the mock function was called, but also whether it was called with the correct parameters:

  test('click returns valid value', () => {
    const mockFn = jest.fn();
    render(<HomeView genres={genres}
                     name={'John'}
                     onGenrePress={mockFn} />);
    fireEvent.press(screen.getByText('Action'));
    expect(mockFn).toBeCalledWith(genres[0]);
  });

This example fires only on a press event but then checks whether the arguments that were passed to the function are correct. Since the Action genre is the first in the genres array, we expect the onGenrePress function to be called with it.

Again, these types of tests are only that easy because we have a good code structure. If we hadn’t split our home page into a business logic and view, we would have to deal with our navigation library, as well as our global state management solution. While this is possible for most cases, it makes your component tests a lot more complex.

Note

It’s a good idea to integrate unit tests, integration tests, and component tests into your CI development process. You should at least run these tests when opening pull requests. If your setup allows, you could also run them on every commit for a faster feedback loop.

I also recommend requiring a certain level of code coverage for the pipelines to pass, to ensure all developers write tests for their code.

All the test types you have learned about so far only use and test parts of your application in a simulated environment. However, that changes when it comes to end-to-end tests.

Understanding end-to-end tests

The idea of end-to-end tests is very simple: these tests try to simulate real-world user behavior and verify that the application behaves as expected. Normally, end-to-end tests work as black-box tests.

This means that the testing framework does not know the inner functionality of the application that is being tested. It runs against the release build of the application, which will be shipped.

Understanding the role of end-to-end testing

At first sight, end-to-end tests seem to be a silver bullet for automated testing. Shouldn’t it be enough to simply test all scenarios of our application with end-to-end tests? Do we even need other test types, such as unit tests, integration tests, or component tests?

The answers to these questions are very simple. End-to-end tests are powerful, but they also have some traits that make them only cover certain scenarios very well. First, end-to-end tests run for a long time, so testing all the functionality of a more complex application with end-to-end tests can take up to multiple hours.

This means they can’t be run on every commit, which makes the feedback loop much longer. So, this scenario can’t be integrated into the CI development process, such as the one described in Chapter 11, Creating and Automating Workflows. Second, end-to-end tests are flaky by nature.

This means that these tests can pass and fail on different test runs without any code changes. One reason for this is that applications can behave differently internally, on different test runs. For example, multiple network requests can be resolved in different orders on different test runs.

This is no problem for end users, but it can be for automated end-to-end tests, where you try to run interactions as fast as possible. Another reason for test flakiness is the real-world conditions the tests are running in.

When the testing device has issues with network connectivity while the test runs, the test will fail, even if it should pass. Modern test frameworks try to reduce these problems as much as possible, but they haven’t been solved completely.

I recommend using end-to-end tests for the most used paths in your application. This can include account creation and login, as well as the core functionality of your product.

Note

As a developer, you should always ensure there’s a balance between ensuring the quality of the product and keeping development speed. Too many end-to-end tests can increase the quality but significantly decrease the speed of your development or release process.

Now that we’ve looked at end-to-end tests in general, let’s start writing our first tests.

Writing end-to-end tests with Detox

Detox is an end-to-end testing framework that was initially developed for React Native applications. It isn’t a real black-box testing framework because it injects its own client into the application, which gets tested. This is done to reduce test flakiness, which works quite well but also can’t prevent flaky tests completely.

This also means that you don’t ship the same binary that was tested. Normally, this should be no problem because you would simply build another binary with the same code and configuration except you would bundle it with the Detox client into your binary, but I wanted to mention it here anyway.

The normal Detox testing process is shown in the following diagram:

Figure 12.1 – Detox testing process

Figure 12.1 – Detox testing process

As you can see, you have to create a production bundle of your application before running your tests. Depending on the machine you create your builds on, as well as the size of your application, this can take some time. Next, you run your tests. After doing so, the testing environment will be torn down so that you can work with the test results.

While this process works fine for running tests, it can be quite annoying while writing tests. Detox works best when using test IDs to identify elements you want to interact with. This means you have to touch your code and add test IDs to these elements.

This also means you have to create a new build every time you have to change anything regarding the test IDs in your code. Fortunately, there is another process you can use while writing your tests. You can also use Detox on development builds, which leads to the following process:

Figure 12.2 – Detox process for writing tests

Figure 12.2 – Detox process for writing tests

When working with development builds, you only have to create your native development build once. As you already know, the JavaScript bundle will be fetched from the Metro server running on your computer during development.

This means you can run your tests. If you realize you have to make changes to your test IDs, you can simply apply them and restart your tests. Then, the development build will fetch the new JavaScript bundle from the Metro server and run the tests. This can save a lot of time.

Now that you know Detox in general, let’s start working with it. This book does not include a detailed step-by-step guide for installing it since the installation steps changed quite frequently in the past. So, please look at the official installation guide in the Detox documentation here: https://bit.ly/prn-detox-start.

If you have trouble getting your Detox tests to work, you can have a look at the example project on GitHub at chapter-12-detox-testing.

Writing Detox tests is very similar to writing component tests because Detox uses Jest as its recommended test runner. However, with Detox, we run the test against the real application in a real-world scenario. This means we don’t have to work with mocking because everything we need is available. Before we start writing our test, we have to add test IDs to the components we want to interact with.

The following example shows a snippet from Home.view.tsx:

<Pressable
  key={genre.name}
  onPress={() => props.onGenrePress(genre)}
  testID={'test' + genre.name}>
  <Text style={styles.genreTitle}>{genre.name}</Text>
</Pressable>

Here, you can see the Pressable component, which is used to display the genres. We added a testID property to this component, which makes it identifiable in our tests.

The following code example shows a simple Detox test for our application. You can also find it in the example project repository under e2e/movie.e2e.js:

describe('Movie selection flow', () => {
  it('should navigate to movie and show movie details',
  async () => {
    await device.launchApp();
    awaitexpect(element(by.id('testAction'))).
      toBeVisible();
    await element(by.id('testAction')).tap();
    await expect(element(by.id('testmovie0'))).
      toBeVisible();
    await element(by.id('testmovie0')).tap();
    await expect(element(by.id('movieoverview'))).
      toBeVisible();
  });
});

First, we tell Detox to launch our app. Next, we wait for the genre with the testAction ID to be visible. Next, we tap the Pressable component. The same is done with the movies, except we don’t use the movie names as IDs but the list index. Finally, we verify that the overview text of the movie is shown.

This example shows the advantages and disadvantages of end-to-end testing very well. On the one hand, we only needed a couple of lines of code to navigate to three different screens and verify the content. This means we can be quite confident that the application will not crash on these screens. On the other hand, it takes a lot of time to build the application, load it into a simulator, start it, and run the test.

While Detox can run on real devices, it’s mostly used with simulators. These simulators can run in CI environments and therefore be integrated into an automated workflow easily.

But you can even go one step further with end-to-end test integration in your automated workflow. While it is useful to run these tests on simulators, it’s even better to run them on real devices. Especially on Android, where you have thousands of different devices, you should at least test the most common ones.

It’s not unlikely that some errors will only occur on specific devices or OS versions. Since you don’t want to buy hundreds of devices for testing, you can use device farms such as AWS Device Farm. Unfortunately, Detox does not work in these environments, so you have to use Appium as the testing framework. This is what we’ll look at next.

Understanding Appium and AWS Device Farm

Unlike Detox, Appium is a real black-box testing framework. It works on your release binary and therefore tests the code you want to ship. It wasn’t primarily designed for React Native, but for native Android and iOS testing. Nevertheless, you can use it for React Native apps very well.

Appium is a very mature framework. At the time of writing, version 2 of Appium is still in progress and not ready to use, so the examples here refer to version 1 of Appium.

The framework consists of multiple parts, which you have to understand when working with Appium. The following diagram shows these different parts:

Figure 12.3 – Appium framework components

Figure 12.3 – Appium framework components

The core of Appium is a Node.js server, which takes test orders from an Appium client. This client is where you will write your tests. It can be written in different languages such as JavaScript, Java, C#, or Python.

Since you don’t want to introduce another language only for writing tests, I recommend going with the JavaScript implementation here. The server then uses an Appium driver to talk to the native testing frameworks, which are used to run the test on real Android and iOS devices.

Appium also provides a desktop application, which has a very useful inspector mode. You can use this mode to find identifiers to write your tests when you don’t work with test IDs.

Since the Appium installation process will change significantly with Appium version 2, this book does not contain a detailed step-by-step guide for the installation. You can find these instructions in the official Appium documentation here: https://bit.ly/prn-appium-installation.

In my opinion, using Appium with React Native is only interesting when it’s combined with a device farm to run your tests on multiple real devices. Otherwise, I would recommend sticking to Detox because it’s easier to install, configure, and maintain. But unfortunately, Detox has no support for running on device farms. So, again, you have to use Appium there.

One of these device farms is AWS Device Farm. It is an Amazon service that gives you access to hundreds of different real mobile device models. You can either upload and install your application and use the devices manually via your web browser or you can run automated tests on these devices.

This automated testing is exactly what we’ll do. The following diagram shows how the process of running Appium tests on AWS Device Farm integrates with your automated workflow:

Figure 12.4 – Running automated tests on AWS Device Farm

Figure 12.4 – Running automated tests on AWS Device Farm

AWS Device Farm can be accessed programmatically from your workflow automation or CI tool (such as Bitrise) or manually via your web browser. In both scenarios, you have to upload an Android APK or iOS IPA file, which should be tested, and a test bundle.

This bundle is a .zip file, which contains the tests as well as some configurations for AWS Device Farm. You can also choose which device pool should be used for testing. A device pool is a collection of devices that you can create in the AWS Device Farm console.

AWS then runs your tests on every device that is part of your device pool and collects the test results. These results are displayed in the AWS Device Farm console and can also be passed back to your workflow automation or CI tool.

The following screenshot shows the overview of a test run in AWS Device Farm:

Figure 12.5 – AWS Device Farm result screen

Figure 12.5 – AWS Device Farm result screen

This overview shows a test run that executed three tests on every device of the chosen device pool. All tests passed except two. This means there is either an error that makes two tests fail on one device type, or that two of the tests are flaky.

This is something you would have to investigate. Fortunately, AWS Device Farm provides logs, screenshots, and video recordings of every test run so that you can find out what is happening with ease.

Since the installation and configuration process for using Appium locally and on AWS Device Farm isn’t trivial, I created a demo repository that you can start from. It also contains a detailed setup and installation guide, as well as useful scripts for running Appium tests locally and creating bundles for running them on AWS Device Farm. You can find it here: https://bit.ly/prn-appium-aws-repo.

Now, let’s summarize this chapter.

Summary

First, you learned why automated testing is important and which types of tests exist for React Native apps. Then, you learned how to write unit and integration tests, as well as component tests, with Jest and react-native-testing.

Finally, you learned about end-to-end testing while covering two different frameworks: Detox and Appium. After completing this chapter, you should understand that automated testing is an essential part of large-scale projects and that every test type is important because it covers different areas.

Now that you have learned about the basics of writing large-scale applications with React Native, in the last chapter of this book, I will provide some tips from my experience as well as an outlook for the next few years regarding React Native.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset