Component Testing in React Native

TL;DR : Here’s what we’ll cover in this article -

  1. Component Tests — Introduction
  2. Component Tests in React
  3. Translating Business Requirements to Tests
  4. Project Setup
  5. UI Testing
  6. Interaction Testing
  7. Testing with React Native Modules

Component Tests — Introduction

As per Martin Fowler’s blog -

A component test is a test that limits the scope of the exercised software to a portion of the system under test.

Component tests can be as large or small as you define your components. The essence of the difference is that component tests deliberately neglect parts of the system outside the scope of the test.

Component tests are usually easier to write and maintain than broad-stack tests. They are also faster to run, since they only hit part of the code base.

Component Tests in React

Now we know that React is a Component-Based library — everything can be split into Components. And we want to test those components.

Makes sense ?

Here’s what React Native documentation has to say about Component Tests -

Component tests could fall into both unit and integration testing

For testing React components, there are two things you may want to test:

Interaction: to ensure the component behaves correctly when interacted with by a user (eg. when user presses a button)

Rendering: to ensure the component render output used by React is correct (eg. the button’s appearance and placement in the UI)

For example, if you have a button that has an onPress listener, you want to test that the button both appears correctly and that tapping the button is correctly handled by the component.

Thus, in this post, we’ll be testing that our Login Form renders correctly and that it handles form functionality correctly (explained in detail below).

Translating Business Requirements to Component Tests

For this example, we’ll work with a very simple Login Form.

UI for our Login Form (see link to Snack)

For our Login Form, let’s say we have the following business requirements -

  1. Login Form Screen (i.e. the component) should display (render) correctly
  2. Login Form should show validation error if -
    a. user submits empty form (no username or password provided) ✅
    b. user submits form with either username or password missing. ✅
  3. On press of submit button (LOGIN), it should show an Alert dialog ( as we’re not doing any server calls in this post). ✅

We can write one test for each of these requirements.

Let’s go !

Project Setup

Environment Requirements -

  1. Node.js
  2. React Native CLI

Creating our project -

Once you’ve isntalled the above 2 dependencies, please create the project by running the following command in your terminal (shell) -

npx react-native-init jestdemoproject

You can find the Expo Snack for the login form here.

Installing the testing library -

For this project, we’ll use the react-native-testing-library instead of using Enzyme for Component Tests.

In my opinion, this is one of the best testing libraries out there for React Native (also recommended in the official RN documentation), preventing a lot of problems RN developers used to face when testing with the Enzyme Testing Library (Enzyme works well with React.JS but needs some workarounds for React Native).

To install this library as a dev-dependency, run the following command in your terminal -

yarn add — dev @testing-library/react-native

Here’s what your package.json file should look like -

{
"name": "jestdemoproject",
"version": "0.0.1",
"private": true,
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"start": "react-native start",
"test": "jest",
"lint": "eslint ."
},
"dependencies": {
"react": "16.13.1",
"react-native": "0.63.2"
},
"devDependencies": {
"@babel/core": "^7.11.1",
"@babel/runtime": "^7.11.2",
"@react-native-community/eslint-config": "^2.0.0",
"@testing-library/jest-native": "^3.3.0",
"@testing-library/react-native": "^7.0.1",
"babel-jest": "^26.2.2",
"eslint": "^7.6.0",
"jest": "^26.2.2",
"metro-react-native-babel-preset": "^0.61.0",
"react-test-renderer": "16.13.1"
},
"jest": {
"preset": "react-native"
}
}

UI Testing

Let’s write our first, very basic component test that will let us test if our component renders correctly. This is what we call Snapshot Testing.

Here’s what React Native Testing documentation has to say about Snapshots :

… a component snapshot is a textual representation of your component’s render output generated during a test run.

Snapshot Testing basically help us identify unknown UI changes made to our component. The documentation also highlights some major drawbacks of Snapshot Testing, so please make sure to check them out.

Here’s my project structure with a basic test already created by React Native at the time of project generation -

Basic Jest Folder Structure

Here’s what the snapshot test looks like -

import 'react-native';
import React from 'react';
import App from '../App';
import {render} from '@testing-library/react-native';
describe('Login Form', () => {
it('renders correctly', () => {
const wrapper = render(<App />);
expect(wrapper).toMatchSnapshot();
});
});

As you can see, we’ve used the render method provided by react-native-testing-library to generate our snapshot. It deeply renders given React element and returns helper methods to query the output components.

(More information on render function can be found here.)

For now, we’ve created the component wrapper only in 1 test. But we’ll need this wrapper instance for each of our tests. Thus, we can make use of beforeEach utility provided by Jest to render our wrapper before each test.

We also want each test to be isolated from each other — thus, we need to perform un-mounting (“clean up”) after each test. This can be done by using afterEach.

(More information on setup and teardown in Jest can be found here.)

import {render, cleanup} from '@testing-library/react-native';describe('Login Form', () => {
let wrapper;
beforeEach(() => {
wrapper = render(<App />);
});
afterEach(() => {
cleanup();
wrapper = null;
});
it('renders correctly', () => {
expect(wrapper).toMatchSnapshot();
});
});

Let’s run our test and see what our Snapshot really looks like. Since this is our first time running the snapshot test, and it is the ONLY test in our project, we can simply do so by running the following shell command -

yarn test

Console output after creating snapshot

If all goes well, you should see a ‘snapshots’ folder created under the __tests__ directory, containing the snapshot (component tree).

Here’s what our Component Tree looks like -

// Jest Snapshot v1, https://goo.gl/fbAQLPexports[`Login Form renders correctly 1`] = `
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#ecf0f1",
"flex": 1,
"justifyContent": "center",
}
}
>
<TextInput
allowFontScaling={true}
onChangeText={[Function]}
placeholder="Username"
rejectResponderTermination={true}
style={
Object {
"borderColor": "black",
"borderWidth": 1,
"height": 44,
"marginBottom": 10,
"padding": 10,
"width": 200,
}
}
testID="input-username"
underlineColorAndroid="transparent"
value=""
/>
<TextInput
allowFontScaling={true}
onChangeText={[Function]}
placeholder="Password"
rejectResponderTermination={true}
secureTextEntry={true}
style={
Object {
"borderColor": "black",
"borderWidth": 1,
"height": 44,
"marginBottom": 10,
"padding": 10,
"width": 200,
}
}
testID="input-password"
underlineColorAndroid="transparent"
value=""
/>
<View
accessibilityRole="button"
accessibilityState={Object {}}
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
testID="submit-button"
>
<View
style={
Array [
Object {},
]
}
>
<Text
style={
Array [
Object {
"color": "#007AFF",
"fontSize": 18,
"margin": 8,
"textAlign": "center",
},
]
}
>
Login
</Text>
</View>
</View>
</View>
`;

As you can see, proper styling has been applied to each element, and each element in the component tree has been given the correct testID and other props as well.

Also, in case we ever update our style or props, our snapshot test will fail, indicating a change in the UI.

Let’s update the ‘backgroundColor’ property of our ‘container’ style to ‘white’ from ‘#ecf0f1’ and re-run our test to see if our snapshot fails.

container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'white',
},

Here’s the error that we get -

As you can see, Jest expected a backgroundColor of ‘#ecf0f1’ but received the value ‘white’ in its place. We can run yarn test -u in order to update our snapshot since our change was intentional. But please be wary of updating snapshots — always make sure you don’t update snapshots without knowing what caused the UI changes.

When we update our snapshot (to make sure our background changes are reflected in our snapshot), we get the following message -

Successful snapshot update

Interaction testing

Now that we have our Snapshot test in place, we need to test the other high-value features of our component that we discussed earlier.

These are -

  1. Login Form should show validation error if -
    a. user submits empty form (no username or password provided)
    b. user submits form with either username or password missing.
  2. On press of submit button, it should show an Alert dialog ( as we’re not doing any server calls in this post).

Here’s what the test looks like -

it("shows 'form-fields-empty' error if user submits without username or password", () => {
const {getByTestId} = wrapper;
const submitButton = getByTestId('submit-button');
fireEvent(submitButton, 'onPress');
const validationError = getByTestId('text-error');
expect(validationError).toBeTruthy();
expect(validationError.props.children)
.toBe(ValidationErrors.FormEmpty);
});

From the wrapper, we de-structure the getByTestId method, which is a utility provided by the render method of RNTL. This method helps us locate the element that we need, in our Component Tree (snapshot), with a testID (or accessibilityID).

Here’s what we did -

First, we locate the ‘Submit Button’ on our login form component tree.
Then, we use RNTL’s fireEvent method to simulate a button onPress event.
Now, since we did not provide any values to our form fields (username and password), we expect the ‘text-error’ field to be visible (toBeTruthy) and to contain the text (as child prop of ‘Text’ element) provided by ValidationErrors.FormEmpty.

export const ValidationErrors = {
FormEmpty: 'Form fields cannot be blank',
UsernameEmpty: 'Username cannot be blank',
PasswordEmpty: 'Password cannot be blank',
};

As expected, our test passes.

But just to ensure our tests are not passing incorrectly, let’s try to fail the test.

In order to do so, instead of expecting the FormEmpty error, let’s expect the ‘PasswordEmpty’ error to show up.

expect(validationError.props.children).
toBe(ValidationErrors.PasswordEmpty);

Now if we run our test, we get -

Failing Test Error

Awesome!!
Looks like our tests are properly validating our form business logic. ✅

Just like we created one test for empty form, we can create 2 more component tests — one where user only provides username, and one where user only provides the password. In order to have our test automatically populate our Text Inputs, we again use the fireEvent API as illustrated below -

it('shows error if user submits only a username', () => {
const {getByTestId} = wrapper;
const userNameText = getByTestId('input-username');
const enteredUserName = 'johnappledoe';
fireEvent(userNameText, 'onChangeText', enteredUserName);
const submitButton = getByTestId('submit-button');
fireEvent(submitButton, 'onPress');
const validationError = getByTestId('text-error');
expect(validationError).toBeTruthy();
expect(validationError.props.children).toBe(ValidationErrors.FormEmpty);
});

Testing with React Native Modules

Up until now, we’ve created pretty basic tests.
However, our components use a lot of React Native built-in modules as well, like Alert, Modal etc. and we usually do need to work with these modules in our tests as well.

For our Login Form, we also have a final requirement as mentioned before —

On press of submit button, Login form should show an Alert dialog ( as we’re not doing any server calls in this post).

However, these components are not understood by Jest. Thus, in order to use the built-in Alert module in our test, we need to do something called mocking.

Here are some excellent articles explaining Mocking in React Native -

https://jestjs.io/docs/en/tutorial-react-native#mock-native-modules-using-jestmock
https://altany.github.io/react-native/0.61/jest/mocking/upgrade/2020/01/25/mocking-react-native-0.61-modules-with-jest.html

Now, let’s take a look at how we can mock our Alert module as follows (inside our test class) —

jest.mock('react-native/Libraries/Alert/Alert', () => ({
alert: jest.fn(),
}));

As you can see, we mocked Alert’s alert() with an empty jest function using jest.fn().

Now, we can test for our Alert dialog to show up after firing events to populate username and password fields, and pressing the Submit button.

it('should show Alert dialog if form validation successful', async () => {
const {getByTestId} = wrapper;
// Enter Username
const userNameText = getByTestId('input-username');
const enteredUserName = 'johnappledoe';
fireEvent(userNameText, 'onChangeText', enteredUserName);
// Enter Password
const passwordText = getByTestId('input-password');
const enteredPassword = 'password';
fireEvent(passwordText, 'onChangeText', enteredPassword);
// Press Submit Button
const submitButton = getByTestId('submit-button');
fireEvent.press(submitButton);
// Check if Alert.alert() has been called
const alertSpy = jest.spyOn(Alert, 'alert');
expect(alertSpy).toHaveBeenCalled();
});

Now, we can see all our tests passing. 🚀🚀🚀🚀🚀

Thus, you can see that with Component Tests like the ones showed above, we as developers can ensure that our business logic works correctly before handing out our code to other stakeholders for testing (QA testers, Project Managers, Clients etc.).

You can find the source code for all necessary files here on this Github Gist.

Thanks for reading!

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store