Anyone who has ever worked in the software development industry will likely agree that the existence of a flawless product is a myth. However, regardless of how impossible our goal of bug-free code might seem, as an engineer, you should strive to eliminate as many mistakes as possible, even before your QA (quality assurance) colleagues get their hands on it.
The best way to eliminate errors and increase confidence in your code is by implementing automated tests. When it comes to React Native applications, your go-to tool should be the Jest framework combined with React Native Testing Library. Both are officially recommended in React’s documentation and better solutions than the somewhat older Enzyme. Now we hopefully got your attention, but first, let's dig into some basics.
The Engineer’s Side of Software Testing
Although there are many different methods of testing software, this article will focus on automated tests created and maintained by engineers. To learn all about the QA testing side, you are welcome to read our ‘It’s Not Over Until It’s QA-ed’ series. While the first two articles will give you information on how QA can benefit your business and comprehensive insights into the QA testing process, you can find a more detailed explanation of testing differences between the engineering and QA side in the third.
Going back to the developers’ side, we can simplify testing as a practice of writing and comparing code. The primary purpose is to identify errors and find missing requirements that make our code incomplete.
To go into more detail, this means we declare how our software is expected to behave in certain situations and compare our expectations to the actual results our software produces in a test run. For instance, in React Native application tests, we define what response our application is expected to make after a user makes a specific interaction.
There are many types of automated tests an engineer can use. In this article, we will focus on the three most common ones:
1. Unit Tests
A unit test describes testing a part of code that can be logically isolated from the software as a whole. A unit or a part is usually presented in the form of a:
- method
- class
- component (common for React applications)
2. Integration Tests
An integration test is used to determine whether multiple code units work together as expected. They are not limited to only testing combinations of units found in our code since we can also use them to test how our code functions when we combine it with external resources, such as:
- API responses
- databases
- external modules
3. End-to-End Tests
End-to-end tests use software to simulate user behaviour and replicate an applications’ live environment. Such tests usually focus on checking whether the required user flows of the application work as expected.
Now we will focus on unit and integration tests, which we can implement using the Jest and React Native Testing Library.
The React Native Testing Library, An Enzyme Replacement
React Native Testing Library (RNTL) is equipped with utility functions that help us access components, query their elements, fire user-interaction events and more. The tool is used on top of a test runner, for example, Jest.
RNTL was created as a replacement for the Enzyme library. While modern software libraries and frameworks seem to be getting more complex, RNTL is like a breath of fresh air. Why? The library was created to simplify testing and guide us towards using better testing practices. Compared to Enzyme, it provides similar but fewer, more focused utilities for test writing. Moreover, the RNTL helps us write tests that resemble how end-users interact with our application.
Many engineers will often recommend the use of RNTL over Enzyme. The reason for this is that the Enzyme library utilities give us access to components’ implementation details, for instance:
- state values
- props
- child components
Comparing our test assertions to such details can have a negative effect. Maintaining tests will be harder because future code refactorings could change these details while keeping the component’s output intact.
Practical Examples of Testing React Native Applications
Let’s look at five of the test examples inspired by common scenarios engineers run into when testing React Native applications.
1. Firing User-Interaction Events and Expecting Content Changes
A very basic example of a test will help us understand how to:
- render a component inside a test runner
- query a button and fire its press event
- check whether pressing the button provides the expected result
The code snippet below shows a Counter component consisting of a button and a text. The component re-renders whenever the count state is updated.
import React, { useState } from "react"
import { Pressable, Text, View } from "react-native"
export default () => {
const [count, setCount] = useState(0)
const increment = () => setCount((c) => c + 1)
return (
<View>
<Text>Current count: {count}</Text>
<Pressable onPress={increment}>
<Text>Increment</Text>
</Pressable>
</View>
)
}
At the start, we render our component inside our test with RNTL’s render utility method. Next, we use the getByText method to query specific elements inside the Counter component (check out other query methods RNTL provides us). As seen in the example, some query methods can also accept regex expressions and make our queries more resistant to future code changes.
Our test example consists of two assertions:
- The first checks if the count state is 0 on the initial render.
- The second checks if pressing the button will cause a re-render, changing the count to be shown as 1.
The press event is one of many user-interaction events we can trigger. You can find other supported events here.
import {fireEvent, render} from '@testing-library/react-native'
import {expect, it} from '@jest/globals'
import Counter from "../src/components/Counter"
it('should increment counter', () => {
const {getByText} = render()
const incrementBtn = getByText('Increment')
const counterText = getByText(/current count: /i)
expect(counterText.props.children).toEqual(['Current count: ', 0])
fireEvent.press(incrementBtn)
expect(counterText.props.children).toEqual(['Current count: ', 1])
})
2. Filling Out a Form and Mocking a Submission Function
In this example, you should first look at a simple form component. Then, to test it, you will simulate a user interaction of filling out the form and submitting it.
import React, { useState } from 'react'
import { View, TextInput, Button } from 'react-native'
export default ({login}) => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
onSubmitLogin = () => {
if (login) login({ username, password })
}
return (
testID="username"
placeholder="Username"
value={username}
onChangeText={text => setUsername(text)}
/>
testID="password"
placeholder="Password"
value={password}
secureTextEntry={true}
onChangeText={text => setPassword(text)}
/>
title="Submit"
testID="btnSubmit"
onPress={onSubmitLogin}
/>
)
}
In our test, we use the fireEvent method to change the values in our form’s text inputs. Then, to test the submission of our form, we create a mock function submitHandler. We do so by using Jest, which will provide us with the toHaveBeenCalledWith method. The method will help us determine if our mock function was called and whether it was called with expected parameters.
import { render, fireEvent } from 'react-native-testing-library'
import { expect, it } from "@jest/globals"
import Login from './Login'
it('should submit login form', () => {
const formData = {username: "user", password: "password123"}
const submitHandler = jest.fn()
const { getByTestId } = render()
fireEvent.changeText(getByTestId("username"), formData.username)
fireEvent.changeText(getByTestId("password"), formData.password)
fireEvent.press(getByTestId("btnSubmit"))
expect(submitHandler).toHaveBeenCalledWith(formData)
})
3. Testing a Custom Hook
A good candidate for a unit test is a test of a custom React hook. For example, the code below shows a custom hook that takes care of setting and manipulating the state of a counter.
import { useState } from "react"
export default ({ initialCount = 0, step = 1 }) => {
const [count, setCount] = useState(initialCount)
const increment = () => setCount((c) => c + step)
const decrement = () => setCount((c) => c - step)
return { count, increment, decrement }
}
We can access our custom hook in tests using RNTL’s renderHook method. The test example below shows how simple it is to access our hook without the need of rendering it inside a component.
import useCounter from "../src/hooks/useCounter"
import { renderHook } from "@testing-library/react-hooks"
import { expect, it } from "@jest/globals"
it("should increment and decrement the counter", () => {
const { result } = renderHook(() => useCounter({ initialCount: 1, step: 2 }))
expect(result.current.count).toBe(1)
result.current.increment()
expect(result.current.count).toBe(3)
result.current.decrement()
expect(result.current.count).toBe(1)
})
4. Handling React Context Providers in Tests
To test certain components, we could be required to wrap them inside of React’s context providers. This example shows us how to rewrite RNTL’s render method to achieve such results.
Below is the code snippet that presents a ThemeProvider component that exposes a simple theme context provider.
import React, { createContext, useState } from "react"
const ThemeContext = createContext(null)
function ThemeProvider({ initialTheme = "light", ...props }) {
const [theme, setTheme] = useState(initialTheme)
return
}
export { ThemeProvider }
Let’s look at how we can rewrite the RNTL’s render function. Essentially, we are creating a simple Wrapper component for our ThemeProvider, which will wrap around any children elements we provide as props.
The actual test is incomplete for simplicity's sake. Still, we managed to wrap a Button component in the ThemeProvider, enabling us to write tests that require access to the context's state values.
import React from "react"
import { render } from "@testing-library/react-native"
import { ThemeProvider } from "../utils/theme"
import Button from "../src/components/Button"
import { it } from "@jest/globals"
function renderWithTheme(ui, { theme = "light", ...options }) {
const Wrapper = ({ children }) => (
<ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
)
return render(ui, { wrapper: Wrapper, ...options })
}
it("renders with dark styles for the dark theme", () => {
renderWithTheme(<Button />, { theme: "dark" })
// We can now test how our button handles being wrapped in a "dark" theme
})
5. Mocking API Calls in Tests With Mock Service Worker Library
Some tests require us to mock API calls and their responses. We can help ourselves with another useful library Mock Service Worker to achieve this.
First, let’s look at a component that fetches an array of sports names from an API.
import React, { useState } from 'react'
import { Text, View, Pressable } from 'react-native'
export default SportsList = () => {
const [sportsData, setSportsData] = useState([])
const [loading, setLoading] = useState(false)
const [hasError, setHasError] = useState(false)
const loadSports = () => {
setLoading(true)
fetch('https://example.com/api/sports')
.then(response => response.json())
.then(data => setSportsData(data))
.catch((error) => setHasError(true))
.finally(() => setLoading(false))
}
return (
Fetch sports
{sportsData.map((sport, index) => (
sport: {sport})
)}
{hasError && Something went wrong!}
)
}
We can mock a server and define mocked responses for specific API calls in our tests. For example, in the code snippet below, we set up our MSW server to mock the GET call used in our SportsList component.
The first test in our example checks whether our component re-renders correctly after a successful fetch from the API.
We purposefully trigger a response with status 500 from our API in the second test. This way, we can test how our component handles server errors.
import React from 'react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { render, fireEvent, waitFor } from '@testing-library/react'
import { expect, it, beforeAll, afterEach, afterAll } from '@jest/globals'
import SportsList from '../SportsList'
const server = setupServer(
rest.get('https://example.com/api/sports', (req, res, ctx) => {
return res(ctx.json(["Tennis", "Basketball"]))
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('loads and displays sports', async () => {
const {getByText, findAllByText} = render()
fireEvent.press(getByText(/fetch sports/i))
const sportsList = await waitFor(() => findAllByText(/sports: /i))
expect(sportsList).toHaveLength(2)
})
it('handles server error', async () => {
server.use(
rest.get('https://example.com/api/sports', (req, res, ctx) => {
return res(ctx.status(500))
})
)
const {getByText, findByText} = render()
fireEvent.press(getByText(/fetch sports/i))
const errorMsg = await waitFor(() => findByText('Something went wrong!'))
expect(errorMsg).not.toBeNull
})
The above wraps up the essential examples of testing React Native applications. After reviewing it, you should be fully covered to do some basic tests to ensure a smooth run of your newest applications.
All in all, there is so much more to this. We recommend checking out React Native Testing Library Documentation and Jest Documentation if you decide to do so.