Intro to React Native Testing Library (RNTL) Part I (useState and TextInput)
“the more your tests resemble the way your software is used, the more confidence they can give you” — Kent C. Dodds
Unit and integration testing has been a well-established concept of front-end development in React. In the early stages, Enzyme initially developed by Airbnb and later transferred to the enzymejs GitHub organization has been the preferred method to perform unit tests. Currently, JEST with the React Testing Library (RTL) seems like a logical choice as tests appear to have mostly transitioned to how the user would actually use the app.
Think about what happens when a button is triggered or when an InputText field gets populated. What changes in the UI for the user? Can we test exactly these changes? While there is plenty of courses and documentation on how to use the React Testing Library, I have not found a lot of materials on how to use the React Native Testing Library (RNTL). Therefore, during the course of several posts, I would like to cover the basics for anyone who may find it helpful.
Initial Setup
Out first step is to install the app. From react native 0.38 onwards, JEST setup comes by difficult when running the npx react-native init
command.
npx react-native init RNTestLibraryApp
Note: If you are on an M1 Mac, you may be getting the error on the screenshot above: Error: Failed to install CocoaPods dependencies for iOS project, which is required by this template. As one of the solutions, cd in the project folder and execute the following command in your terminal.
cd RNTestLibraryApp
bundle install
Open the project folder in your VS Studio Code. Open the App.js and delete all of its content. We will start with the following code that you can directly paste.
import { View, Text } from 'react-native'import React from 'react'const App = () => {return (<View><Text>App</Text></View>)}export default App
Setting Up the App component
Now the next goal after pasting the bare App component is to create a screen with two TextInput fields, two state variables (ursename and userage), and two functions to be called when the username and userage are changed. This is the reason that we would need a TextInput component from react-native. After importing TextInput we can start setting up the component. Reading the official react native documentation, we see that onChangeText is a callback that is called when the TextInput’s value changes. We can set the changes in the state using the onChangeText prop.
const onChangeUserName = name => {setUsername(name);};<TextInputstyle={styles.input}onChangeText={text => onChangeUserName(text)}value={username}placeholder="username"/>
The completed App.js component should look like this:
import {SafeAreaView, View, Text, StyleSheet, TextInput} from 'react-native';import React, {useState} from 'react';const App = () => {const [username, setUsername] = useState('');const [userage, setUserage] = useState('');const onChangeUserName = name => {setUsername(name);};const onChangeUserAge = age => {setUserage(age);};return (<SafeAreaView><View style={styles.wrapper}><Text>Your new username is </Text><Text>{username}</Text><TextInputstyle={styles.input}onChangeText={text => onChangeUserName(text)}value={username}placeholder="Username"/><Text>Your new user age is </Text><Text>{userage}</Text><TextInputstyle={styles.input}onChangeText={text => onChangeUserAge(text)}value={userage}placeholder="Userage"/></View></SafeAreaView>);};
export default App;
const styles = StyleSheet.create({input: {height: 40,margin: 12,borderWidth: 1,padding: 10,},inputContainer: {justifyContent: 'flex-start',flexGrow: 1,},});
To make sure that these changes work run the following commands.
For an iOS device or a simulator
npx react-native run-ios
For an Android device or a simulator
npx react-native run-android
Writing Our First Test
So far so good but something is missing. We have not yet implemented any actual tests. Furthermore, we haven’t even installed the library. In the root folder of our project execute the following commands:
yarn add --dev @testing-library/react-native
yarn add --dev @testing-library/jest-native
The official testing library documentation, states that the testId
attribute is used by the getByTestId()
querySelector. We will put the attribute on our TextInput component. The convention that I follow, though certainly not a standard, is screenName.nameOfValue. In our case, the testId attributes of the two TextInput components would be App.username and App.userage. Change App.js accordingly.
<TextInputstyle={styles.input}onChangeText={text => onChangeUserName(text)}value={username}placeholder="Username"testID="App.username"/>...<TextInputstyle={styles.input}onChangeText={text => onChangeUserAge(text)}value={userage}placeholder="Userage"testID="App.userage"/>
On the root level of the project, we create a __tests__
folder together with a App.test.js
file. The JEST API searches for files under the __tests__
folder. A good practice here is to name the test files with the same name as the file name of the component or screen we would like to test. The test file name for App.js
would be App.test.js
.
The file structure for the project should look like this now. We would like to create a matching snapshot of our component for the tests. The toJSON
prop provides us with the rendered component JSON representation for snapshot testing. We are ready to write our first test in the App.test.js file.
import React from 'react';import 'react-native';import App from '../App';import {render} from '@testing-library/react-native';test('renders correctly', () => { const {toJSON} = render(<App />); expect(toJSON()).toMatchSnapshot();});
We will be checking whether the test passes by running yarn test
or npm run test
. In our case, our single test in the App.test.js file should now pass. Let’s celebrate this by putting our repo on GitHub (link at the end). I will make a new branch for every milestone we reach in the app. The current one is called first-test
. It is a good practice to automatically rerun jest when a file changes. In order to do this, we need to update thetest
script in our package.json file in the following manner.
Remember the quote at the beginning of the post that our tests should resemble how our app is used. When we set a piece of data in the state, we would likely use it in the same component or pass it down as a prop to a child component. For the subsequent tests, we would be checking when triggering an event on the TextInput would change the value in the Text component displaying the state. Therefore, we are testing the changes of the values in the relevant UI element that display the state values.
The describe
block is a JEST global that groups together several tests. The tests that fall within the scope of a describe block are usually related such as testing the same value. Here we will group together text input field data changes. Since describe
is a JEST global, we do not need to import it.
It is now time to write these tests. But how do we know when an InputText field is populated? In the React Native Testing Library documentation, we see that the fireEvent.changeText
invokes changeText
event handler on the element or parent element in the tree. This sounds exactly like what we need. In order to find the Text
element on the page, we need to use one of the query methods (get, find, query). The getByText
method takes a string or a regular expression and finds any visible text on interactive and non-interactive elements. This is exactly what we need to test whether the username and userage are displayed as Text. We use toBeTruthy
to ensure that a value is on the component in a boolean context.
This is our test for the username TextInput change.
test('Displays a username, if the username field has been completed', () => {const INPUT_TEXT = 'John';const {getByTestId, getByText} = render(component);const userNameTextInput = getByTestId('App.username');fireEvent.changeText(userNameTextInput, INPUT_TEXT);const username = getByText(INPUT_TEXT);expect(username).toBeTruthy();});
Similarly, this is our test for the userage TextInput change.
test('Displays a userage, if the userage field has been completed', () => {const INPUT_TEXT = '25';const {getByTestId, getByText} = render(component);const userAgeTextInput = getByTestId('App.userage');fireEvent.changeText(userAgeTextInput, INPUT_TEXT);const userage = getByText(INPUT_TEXT);expect(userage).toBeTruthy();});
Our entire App.test.js file should look like this.
import React from 'react';import 'react-native';import App from '../App';import {render, fireEvent} from '@testing-library/react-native';test('renders correctly', () => { const {toJSON} = render(<App />); expect(toJSON()).toMatchSnapshot();});
const component = <App />;
describe('Check input field data', () => { test('Displays a username, if the username field has been completed', () => { const INPUT_TEXT = 'John'; const {getByTestId, getByText} = render(component); const userNameTextInput = getByTestId('App.username'); fireEvent.changeText(userNameTextInput, INPUT_TEXT); const username = getByText(INPUT_TEXT); expect(username).toBeTruthy();});test('Displays a userage, if the userage field has been completed', () => { const INPUT_TEXT = '25'; const {getByTestId, getByText} = render(component); const userAgeTextInput = getByTestId('App.userage'); fireEvent.changeText(userAgeTextInput, INPUT_TEXT); const userage = getByText(INPUT_TEXT); expect(userage).toBeTruthy();});});
We run our tests again using yarn test
or npm run test
and we should now see that they all pass. We have reached the final milestone in part I of this tutorial. It is time to commit and push to a new branch called input-field-tests
. In the following parts, we will add Redux, submit the data of the form by using a Pressable
in our component, fetch and post data, and write the relevant tests.
GitHub Repo: https://github.com/rosen777/RNTestLibraryApp