Unit Testing in React Native
Implementing Reacts best practices for unit testing in React Native.
Unit Testing...the thing you know you should be doing but haven't got around to yet. The truth is jumping right into the code is more fun but at a certain point you end up running into a bug in your UI, you go for the classic console.log and grind it out. This can work in certain circumstances but what happens when your UI get more complex? Now you run into a bug, have to navigate through 4 screens, wait for asynchronous data from a server, then interact with your UI to make it blow up and console.log. How much time do we waste going through this loop over and over while debugging? I think we can do better than that... In this article we will briefly go over the current state of unit testing react components in the react-native ecosystem as well as:
Library Options
Out of the box a react-native project comes with jest and a package called react-test-renderer. This is a great place to start but this library really only gets us half way there, we want to go beyond just knowing that our component rendered as expected, we want to have an easily maintainable testbase that does not include implementation details and closer resembles how the application is going to be used. Luckily there is a library that focuses on just that, react-testing-library. And even luckier, the great team at callstack has given us react-native-testing-library which enforces the same testing practices for react-native.
To start using the react-native-testing-library package simply run npm install --save-dev @testing-library/react-native in your react native project.
note:
There are also additional jest matchers available for this library at this time, we will get into why these are helpful and how to include them later in this article.
Project Structure
The core of our testing will be done with Jest and Jest is very smart. It knows to look for any files under the __tests__ directory or any files with the .test.js file extension. You may have already noticed that your project already includes a base test suite at the root of your project inside of the __tests__ directory that includes a simple test for App-test.js. This is a great place to start and depending on your projects needs this might be a maintainable solution for constructing the rest of your testbase but I would argue that as your project grows continuing to come back to this directory and maintain individual component tests will become cumbersome. A better strategy might be adding the .test.js to a file as the same name as your Component and coupling it directly with that Component. Something like:
This not only solidifies that the test is integral to the development of the Component but also allows the developer working on it easier access to the "blueprint" of it or what is actually making this Component work.
Every projects needs are different and both solutions make sense in different scenarios. For the rest of the example we will be using the coupled test solution. Follow along at https://github.com/Staceadam/blog-examples/tree/master/react-native/testing/UnitTests
What makes a good unit test?
Now that we have some of the basics of jest and react-native testing out of the way lets get into actually testing a react-native component and what makes a good component unit test. To start make sure that you have added the @testing-library/react-native to your project with npm install --save-dev @testing-library/react-native. A basic unit test has 3 things
- description
- setup
- assertions
//description
it('adds', () => {
//setup
const addTwo = (num) => num + 2
//assertions
expect(addTwo(2)).toBe(4)
})
Jest is magically giving us access to a base set up functions like it and expect but also an ever growing list of "matcher" functions like toBe or toHaveReturned. More information on base jest matchers here https://jestjs.io/docs/expect.
Lets apply the same process to testing a react-native component using the newly installed @testing-library/react-native package. Given a very basic example Header Component
function Header({ title, onPress }) {
return (
<View>
<Text>{title}</Text>
{/* testID prop is only used in development to target elements */}
<Button testID="button" title="Testing" onPress={onPress} />
</View>
)
}
We can make some basic assumptions about it, it should be able to render whatever title prop we pass it and it should fire off any onPress prop we pass it. @testing-library/react-native exposes render and fireEvent functions for us to be able to easily target and interact with the rendered elements of a Component. To test the title we could do something like
//description
it('has title', () => {
//setup
const testTitle = 'testing'
const { getByText } = render(<Header title={testTitle} />)
//assertions
expect(getByText(testTitle))
})
Awesome! Now lets test our onPress
//description
it('fires onPress', () => {
//setup
const mockFn = jest.fn()
const { getByTestId } = render(<Header onPress={mockFn} />)
fireEvent.press(getByTestId('button'))
//assertions
expect(mockFn).toHaveBeenCalledTimes(1)
})
We can run jest with npm run test and see that both of our tests are passing at this point. Next let's see if we can get into a more complex example with user introduced side effects.
More Complex
Jest is a very comprehensive library with a great default set of matcher functions (toBe toHaveBeenCalledTimes) but it does not accommodate for some of the react-native specific matching we might want in our tests. That is where @testing-library/jest-native comes in, @testing-library/jest-native gives us access to some much needed additional matchers like toHaveStyle and toBeDisabled to help us make react-native specific assertions about our components and give us more confidence in our tests. To include it in a project you run npm install --save-dev @testing-library/jest-native and then modify your package.json 's jest block to include:
//package.json
{...
"jest": {
"preset": "react-native",
"setupFilesAfterEnv": [
"@testing-library/jest-native/extend-expect"
]
}
}
Now we are ready to make a lot more powerful assertions about our more complex react-native Components.
Lets try to test a form that takes in multiple user inputs and submits them.
A basic implementation of a react-native form might look like
import React, { useState } from 'react'
import {
View,
TouchableOpacity,
Text,
FlatList,
TextInput,
StyleSheet
} from 'react-native'
function Form({ inputs, onSubmit }) {
const [form, setForm] = useState({})
return (
<FlatList
keyExtractor={item => item.key}
data={inputs}
renderItem={({ item }) => (
<View style={styles.inputContainer}>
{/* testID prop is only used in development to target elements */}
<Text testID="text">{item.question}</Text>
<TextInput
{/* testID prop is only used in development to target elements */}
testID="input"
style={styles.input}
onChangeText={text => {
setForm(state => ({
...state,
[item.question]: text
}))
}}
/>
</View>
)}
ListFooterComponent={() => (
<TouchableOpacity onPress={() => onSubmit(form)}>
<Text>Submit</Text>
</TouchableOpacity>
)}
/>
)
}
const styles = StyleSheet.create({
inputContainer: { justifyContent: 'space-between', marginBottom: 10 },
input: { borderBottomWidth: 1, borderBottomColor: 'black' }
})
Form.defaultProps = {
inputs: [
{ key: '1', question: 'What is your name?' },
{ key: '2', question: 'What is your favorite color?' }
],
onSubmit: () => {}
}
export default Form
Notice that we added testID 's for both our Text and TextInput elements which allows us to easily target them directly in our test.
Looking at our Form Component we can start to think about what kind of tests would make sense for it. I think testing:
- If it renders all of the questions we give it
- If it updates our inputs and submits them with the updated values
is a reasonable place to start.
First lets create an array of mockInputs that we can use throughout our tests.
const mockInputs = [
{ key: '1', question: 'What is your favorite programming language?' },
{ key: '2', question: 'Why is it javascript?' }
]
Next we can see if our text elements are rendering the questions from our mockInputs. To do this we make use of getAllByTestId which will return all of our text elements and toHaveTextContent to see if what each element rendered is correct.
it('renders input questions', () => {
const { getAllByTestId } = render(<Form inputs={mockInputs} />)
const questions = getAllByTestId('text')
expect(questions[0]).toHaveTextContent(mockInputs[0].question)
expect(questions[1]).toHaveTextContent(mockInputs[1].question)
})
Now that we know our questions are getting rendered as expected we can move on to making sure our inputs are getting updated and that the values are getting submitted when our Touchable element is getting fired. We will make use of both getAllByTestId to target our inputs and getByText to target the submit button. fireEvent from @testing-library/react-native will be responsible for simulating our TextInput and TouchableOpacity interactions. In order for jest to recognize a function being called we will use jest.fn() as our mock function.
it('submits with updated input values', () => {
const mockFn = jest.fn()
const { getAllByTestId, getByText } = render(
<Form inputs={mockInputs} onSubmit={mockFn} />
)
const inputs = getAllByTestId('input')
fireEvent.changeText(inputs[0], 'javascript')
fireEvent.changeText(inputs[1], 'its the best.')
fireEvent.press(getByText('Submit'))
expect(mockFn).toBeCalledWith({
[mockInputs[0].question]: 'javascript',
[mockInputs[1].question]: 'its the best.'
})
})
Sweet! We now have a decent base set of tests that not only give us added confidence that our Form Component is functioning as expected but also give us a blueprint into how the component is expected to be used.
Mocking Native Modules
As your react-native application continues to mature you are going to find yourself relying on some native packages such as react-native-async-storage or react-native-device-info. When testing the Components you end up using these packages in, Jest will not know how to resolve them and your tests will blow up. We need a way to mock these native modules so our tests can solely focus on the functionality that we are adding. There are a couple ways to do this:
- create a __mocks__ directory at the root of our project that contains files with the same name of the module including mocked functions resembling the modules api
- create a jest setup file and then inject it into our jest 's setupFilesAfterEnv property in our package.json
Picking one depends on your projects needs, if you have multiple react-native projects that are sharing the same native modules it might make sense to create a jest setup file and share the mocks between them. For this example we will use the __mocks__ strategy.
We can start by adding a react-native dependency that has native code. npm i react-native-device-info will give us a native module that exposes an api for getting various device information. After installing and installing the pods we can create a Component that uses this module.
import * as React from 'react'
import { View, Text, StyleSheet } from 'react-native'
import { getDeviceId, getApplicationName } from 'react-native-device-info'
function DeviceInfo() {
return (
<View style={styles.container}>
<Text style={{ fontWeight: 'bold' }}>{getDeviceId()}</Text>
<Text style={{ fontWeight: 'bold' }}>{getApplicationName()}</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
marginTop: 10
}
})
export default DeviceInfo
Next we can make a test file for the Component, DeviceInfo.test.js.
import * as React from 'react'
import { render } from '@testing-library/react-native'
import DeviceInfo from './DeviceInfo'
it('has DeviceId', () => {
const { getByText } = render(<DeviceInfo />)
})
it('has ApplicationName', () => {
const { getByText } = render(<DeviceInfo />)
})
If we were to run our test suite now we would get the following error:
FAIL src/components/DeviceInfo.test.js
● Test suite failed to run
Invariant Violation: Native module cannot be null.
at invariant (node_modules/invariant/invariant.js:40:15)
at new NativeEventEmitter (node_modules/react-native/Libraries/EventEmitter/NativeEventEmitter.js:49:7)
at Object.<anonymous> (node_modules/react-native-device-info/lib/commonjs/internal/asyncHookWrappers.ts:29:34)
at Object.<anonymous> (node_modules/react-native-device-info/lib/commonjs/index.ts:3:1)
Jest does not know how to resolve the methods getDeviceId, getApplicationName that we are trying to use from react-native-device-info , we need to create a mock implementation of them.
To do that first create a __mocks__ directory at the root of the project and add a react-native-device-info.js under it. Now anything we export from this file jest will be smart enough to use in place of any methods being used from that modules api.
Now we can export functions of the same name that we used in our DeviceInfo Component that have a mock implementation, something like
export const getDeviceId = jest.fn(() => 'iPhone')
export const getApplicationName = jest.fn(() => 'UnitTests')
Jest is happy now. Running our DeviceInfo tests work and we are free to make some additional assertions about the Component.
Conclusion
Adding a solid test suite to your application can greatly increase developer productivity as well as decrease the amount of bugs that get introduced into it. Unit tests can give you and your team added confidence that what you are shipping is going to work as expected.