How to Test Your React Native App with React-native-testing-library

React Native

react-native-testing-library

test

Introduction 

How do you test your React Native app? Are you using Enzyme and doing mostly unit tests? Well we've done that for quite some time at BAM and at some point, we decided to change.  Indeed this way of testing raised 3 issues:

  • It could be painful to write so many tests.

  • Most of the tests were unit tests. It implies that the connections between the different elements of our app (React components, global state, api calls) were not tested together. 

  • The tests depended heavily on the implementation of the features. Every time a feature was refactored, the tests for this feature had to be changed or written all over again.

That's why at the beginning of a new project, my team and I wondered: why not try to use something that will fix those issues? And the "something" happens to be react-native-testing-library. 


Introducing React Native Testing Library

🔎  Purpose

react-native-testing-library is a small library compatible with jest that enables you to write tests similar to the way your React Native application is used. It is inspired by react-testing-library, one of the standards to test React apps. Its author Kent C Dodds explains that: "The more your tests resemble the way your software is used, the more confidence they can give you". That means your tests should be as independent as possible from implementation details.

Why?

  • Your tests won't break every time you refactor your code

  • Your tests will break when the functionality is broken for the user 

With react-native-testing-library, we will mainly write "integration tests". By integration test, I mean a test testing a small functionality inside a page. It starts with an interaction between the user and the app and ends with a visual feedback.

It's not unit testing because we won't just test our UI component but all the code involved in this functionality (redux / api calls etc.).  We will need to mock the native code as well as the HTTP calls though.

 

🛠️ SETUP 

If you want to try out the library on your own project, install it by running:

yarn add --dev react-native-testing-library

If you just want to see what it can do, I made a small demo app.  To follow along you can clone my repository. Then follow the instructions in the README to get everything up and running:

git clone https://github.com/MattAgn/NewsletterApp.git
 

Here is what the app looks like:

newsletter app

It is a single screen app where you can enter an email to subscribe to a newsletter. If you enter a valid email, you have a success message. If your email is badly formatted, you'll receive an error message. For simplicity's sake, the Subscription API used does not really subscribe you to any newsletter. It only checks whether the email given is properly formatted or not.

I was gonna give you the code for the page but we don't really need it. After all I just said we would write tests independent from implementation details. Let's prove it!

 

✏️ writing our first test

Once you have cloned the repository, open it in your favorite text editor and open the file src/basic-version/pages/Home/__tests__/Home.test.tsx. That's where we'll write our test. You don't have to look at the other files just yet.

 

SETUP OF OUR TEST ENVIRONMENT

We need a few things to write our tests, let's import them :

import React from 'react';
import fetchMock from 'fetch-mock';
import { render, waitForElement, fireEvent } from 'react-native-testing-library';
import { NEWSLETTER_ENDPOINT } from '../../../../complex-version/api/config';
import { Home } from '../Home';
import { wording } from '../../../../utils/wording';
  • fetchMock is a library to mock HTTP calls. We'll use its most simple features in this test but you can check almost every aspect of your fetch calls with it

  • Home is the component representing the main page of our app

  • wording is an object containing all the strings used in our app

  • NEWSLETTER_ENDPOINT is the URL to call in order to subscribe to the newsletter

  • The rest will be detailed later

 
OUR FIRST TEST

Here is the code to test a successful newsletter subscription:

test("should display successful message on successful subscription", async () => {
fetchMock.post(NEWSLETTER_ENDPOINT, 200);
const page = render(<Home />);
const EmailInput = page.getByPlaceholder(wording.emailPlaceholder);
const ValidateButton = page.getByText(wording.validateEmail);
fireEvent.changeText(EmailInput, 'hello@bam.co');
fireEvent.press(ValidateButton);
const SuccessMessage = await waitForElement(() =>
page.queryByText(wording.subscriptionSuccessful)
);
expect(SuccessMessage).toBeTruthy();
})

Let's see what each line does now!

 

SET UP OF THE TEST
fetchMock.post(NEWSLETTER_ENDPOINT, 200);
const page = render(<Home />);

The first thing we do is mock everything that needs to be mocked. In our case, it is the HTTP call to our Subscription API. For that, we will use fetchMock and simply mock the HTTP call and tell fetch to return a 200 response when it is called. It is important to note that we could be more precise. We could check the request parameters as well and send a custom response if required.

Then we render the page and save it in a constant. It will provide us with some utilities to look into our DOM.

 

WHAT THE USER SEES 

Next step is to find out what the user sees when he arrives on the page and which elements he will interact with. Here, we see an input with an email placeholder, so that's what we look for, just as the user would. We do the same for the button and get it by its text. 

const EmailInput = page.getByPlaceholder(wording.emailPlaceholder);
const ValidateButton = page.getByText(wording.validateEmail);

As you can see, we're not fetching the elements with ids or css classes, the user does not know all that. If we were to look for an id for example, someone might inadvertently change the wording of the button. Then the test would still pass even though the user won't be able to see the correct button anymore. 

However, there are indeed cases where using the id is the only way. For example when you want to find an element that does not have any text, label or any other property which could help you find it. 

 

WHAT THE USER DOES 

Now that we found our components in our DOM, it's time we interact with them. First, the user changes the text inside the email input and then presses the validate button.

fireEvent.changeText(EmailInput, 'hello@bam.co');
fireEvent.press(ValidateButton);

fireEvent is a method provided by react-native-testing-library. It enables you to fire pretty much any native-like event in your DOM.

 

WHAT FEEDBACK THE USER SHOULD EXPECT 

Finally we wait for the success message to appear on the page and check that it does appear in the DOM. 

const SuccessMessage = await waitForElement(() =>
page.queryByText(wording.subscriptionSuccessful)
);
expect(SuccessMessage).toBeTruthy();

waitForElement is a method provided by react-native-testing-library that either waits for your element to appear or times out. It is very useful each time you have asynchronous code running.

You can notice the use of queryByText and not getByText that we used earlier. getByText throws an error if it does not find the element while queryByText returns "undefined" if it does not find it. That's why we use queryByText every time we want to assert the presence of an element with expect afterwards.

 

📈 100% coverage no matter the implementation 

THE IMPLEMENTATION

Do you remember you still haven't seen the code? Now is the time to show you! For the app we just wrote a test for, I actually wrote 2 different implementations. The first one is very basic with only local state and basic components. The second one is more complex and uses redux, sagas, formik and toaster messages. And guess what? The tests are the same for both implementations!

Well almost the same. Since the complex version uses redux, we need to add a Redux Provider wrapping the page. And it also uses a toaster rendered outside the page so we have to include it as well. This is something that we will have to do for every test so let's just make a helper that will do the job. 

export const renderPage = (page: ReactElement) => {
const sagaMiddleware = createSagaMiddleware();
const store = createStore(rootReducer, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(watchAll);

return render(
<Provider store={store}>
{page}
<Toaster />
</Provider>
);
};

As you can see, we are not mocking the store nor the sagas. Everything is running as it usually would in your app. That means more chances to catch a bug concerning the connection between our component and our sagas for example. 

All we have to do now is replace the render method we used in the first test and use our renderPage helper instead. Here is the final code for this second test:

test("should display successful message on successful subscription", async () => {
fetchMock.post(NEWSLETTER_ENDPOINT, 200);
const page = renderPage(<Home />);
const EmailInput = page.getByPlaceholder(wording.emailPlaceholder);
const ValidateButton = page.getByText(wording.validateEmail);
fireEvent.changeText(EmailInput, 'hello@bam.co');
fireEvent.press(ValidateButton);
const SuccessMessage = await waitForElement(() =>
page.queryByText(wording.subscriptionSuccessful)
);
expect(SuccessMessage).toBeTruthy();
})
 
THE FINAL COVERAGE

Ok so let's sum up everything. So far we've written two tests to verify that the subscription works. One for the basic version and another for the complex one. It's now easy for you to write the last two tests to check the case where the subscription fails for both versions.  In total that makes 4 tests, each about 9 lines long.

It is time to check out what code coverage we get with these 4 tests. To do so, go at the root of the project and run :

yarn jest --coverage

You should see something like the following :

Capture d’écran 2019-08-25 à 00.26.06

As you can easily notice, we obtained a 100% coverage! Hooray!! But if I show you that, it's not to tell you that you can get 100% coverage on your app with react-native-testing-library. Indeed some rare scenarios and functions are still hard to test with this library.

No, what I wanted to show you with the coverage was that we did go through all our code. Our tests passed through our reducer, our actions, formik etc... That means the different pieces of our app are working together just as they would when the user uses the app.

 

Test strategy 

After writing some tests with react-native-testing-library, we chose the following test strategy:

  • Unit tests for all components with snapshots

  • Unit tests for all services / helpers (mostly when we have either some logic and/or edge cases) 

  • Integration tests for all pages and each of their functionalities

  • End to end tests for key functionalities

You may wonder why we don't write unit tests covering our data flow (like redux or apollo). Well it's just because we don't really need them. Thanks to the great debugging tools we have now, it's quite easy with an integration test to find out where the bug lies.

Tell me in the comments what you think about this test strategy!

 

Conclusion

 

I would be lying if I said we had a perfect time writing tests with react-native-testing-library so far in our team. It has its pros and cons.

⛔  Cons 

  • Very few examples: although react-testing-library is quite popular for react apps, it is not yet the case for react-native-testing-library. At first you will certainly struggle a bit to determine how to test certain types of functionalities. That's why I have assembled different test cases in a public repository here. Feel free to add your own examples if you have any!

  • Some edge cases hard to test: we stumbled upon some cases where it was quite difficult to write integration tests. Mocking native code for example happened to be rather hard for some features. But then, I don't think it is inherent to this library in particular but more to testing react-native apps.

✅  PROS 

  • Tests more maintainable: each time you will refactor the code behind a feature, you won't have to rewrite your tests

  • Fewer tests to write to get better coverage: those tests cover the entirety of your code, from your component to your external api call via your redux actions or sagas.

  • Tests more robust: your tests will be closer to the way a user would be using your app. That means you will have more chances of catching bugs and regressions before you ship to production.

 

To sum up, we had quite some difficulties at first writing tests with react-native-testing-library and took some time to set everything up. However it all proved worth it after some time. We can now write integration tests quickly that give us the confidence they are intended for. Besides, we have a test strategy where we are writing fewer tests than before without losing coverage.

 

📖  KEEp reading

If you want to know more about the philosophy and the reasons behind react-testing-library, I recommend these articles written by Kent C Dodds: