Updated August 14, 2019
Component Testing with React Native Testing Library
As we get deeper into testing our React Native code we'll want to test our components. I've found the most flexible way to do so is by using the react-native-testing-library
package. This package gives us the methods needed to thoroughly test our components while also minimizing complexity.
Setup
Assuming you already have Jest installed in your project (which you probably do because React Native has shipped with it by default for years) there are a few additional steps we'll take to set up our integration tests.
If you want more detail on configuring your test environment please review this previous React Native School lesson.
Terminal
yarn add --dev react-native-testing-library
If you don't already have react
and react-test-renderer
installed make sure to add them as well as they're peer dependencies of react-native-testing-library
.
Note: If you're following along with Expo you'll need to mock ScrollView to get your tests working.
Starting Code
This is the code we'll be using to get started. It fetches data from a server and renders that list of data. A running example of the code can be found at the end of this lesson. Just use this code for reference right now.
App/screens/PostList.js
import React from 'react';
import {
SafeAreaView,
Text,
TouchableOpacity,
StyleSheet,
FlatList,
View,
} from 'react-native';
import { api } from '../util/api';
const styles = StyleSheet.create({
row: {
paddingVertical: 8,
paddingHorizontal: 10,
},
});
export const PostRow = ({ item, index, onPress }) => (
<TouchableOpacity style={styles.row} onPress={onPress}>
<Text>{item.title}</Text>
</TouchableOpacity>
);
class PostList extends React.Component {
state = {
posts: [],
loading: true,
error: null,
};
componentDidMount() {
this.getPosts();
}
getPosts = () => {
api('/posts')
.then((posts) => {
this.setState({ posts, loading: false, error: null });
})
.catch((error) => {
this.setState({ loading: false, error: error.message });
});
};
render() {
return (
<SafeAreaView>
<FlatList
data={this.state.posts}
renderItem={({ item, index }) => (
<PostRow
item={item}
index={index}
onPress={() =>
this.props.navigation.navigate('Post', { postId: item.id })
}
/>
)}
keyExtractor={(item) => item.id.toString()}
/>
</SafeAreaView>
);
}
}
export default PostList;
We'll also create a test file to get started. Jest will automatically pick this file up with its default config.
App/screens/__tests__/PostList.test.js
import React from 'react';
import {
render,
waitForElement,
fireEvent,
} from 'react-native-testing-library';
import PostList, { PostRow } from '../PostList.js';
Writing Tests
With everything set up we can now go ahead and start writing our tests. I'm going to be roughly following a TDD (Test Driven Development) approach while doing so.
Renders post list
First we'll check if our list renders the list of posts returned by the server.
App/screens/__tests__/PostList.test.js
// ...
describe('PostList', () => {
test('renders a list of posts', async () => {
fetch.mockResponseOnce(
JSON.stringify([
{ id: 1, title: '1' },
{ id: 2, title: '2' },
])
);
const { queryByTestId, getByTestId } = render(<PostList />);
expect(queryByTestId('post-row-0')).toBeNull();
await waitForElement(() => {
return queryByTestId('post-row-0');
});
expect(getByTestId('post-row-0'));
});
});
As you can see we're mocking the API response with a set of data that the component will use. We then render the component with react-native-testing-library
.
We can analyze the result with queryByTestId
. The test id is a means through which we can look for an element in our component tree. Initially we're using query
to check that the first row doesn't exist because the API request is asynchronous - it will take some time to get a response back. By using query
we won't get an error even though that element doesn't exist.
We then wait for that element to exist. If it doesn't appear then the test will fail.
If you run this test now it will indeed fail because we have no testID
of post-row-0
.
To fix this all we have to do is add the testID
to our PostRow.
App/screens/PostList.js
// ...
export const PostRow = ({ item, index, onPress }) => (
<TouchableOpacity
style={styles.row}
onPress={onPress}
testID={`post-row-${index}`}
>
<Text>{item.title}</Text>
</TouchableOpacity>
);
// ...
Renders loading message while waiting for response
Before our list displays we want a loading indicator of some sort to be displayed.
App/screens/__tests__/PostList.test.js
// ...
describe('PostList', () => {
// ...
test('renders a loading component initially', () => {
const { getByTestId } = render(<PostList />);
expect(getByTestId('loading-message'));
});
});
To fix this error I'm leveraging the FlatList
ListEmptyComponent
to render some text that we're loading.
App/screens/PostList.js
// ...
class PostList extends React.Component {
// ...
render() {
return (
<SafeAreaView>
<FlatList
testID="post-list"
data={this.state.posts}
renderItem={({ item, index }) => (
<PostRow
item={item}
index={index}
onPress={() =>
this.props.navigation.navigate('Post', { postId: item.id })
}
/>
)}
keyExtractor={(item) => item.id.toString()}
ListEmptyComponent={() => {
if (this.state.loading) {
return <Text testID="loading-message">Loading</Text>;
}
}}
/>
</SafeAreaView>
);
}
}
export default PostList;
Since we're using the testID
to look for our element we can make that loading message be whatever we want - text, an indicator, another component, etc. It doesn't matter as long as the testID
is set. That's what we're looking for here - that we have some sort of loading indicator. The test doesn't care about what that indicator is.
Renders no results message if no results found
Next we'll handle the case where we get a response from our server but there are no results (an empty array).
App/screens/__tests__/PostList.test.js
// ...
describe('PostList', () => {
// ...
test('render message that no results found if empty array returned', async () => {
fetch.mockResponseOnce(JSON.stringify([]));
const { getByTestId } = render(<PostList />);
await waitForElement(() => {
return getByTestId('no-results');
});
expect(getByTestId('no-results'));
});
});
We need to mock the fetch response and wait for the element to appear again. We then want to check that we have some sort of indicator for no results. Again, we don't care what it is just that something exists.
To fix the error we add a component to our ListEmptyComponent
prop.
App/screens/PostList.js
// ...
class PostList extends React.Component {
// ...
render() {
return (
<SafeAreaView>
<FlatList
testID="post-list"
data={this.state.posts}
renderItem={({ item, index }) => (
<PostRow
item={item}
index={index}
onPress={() =>
this.props.navigation.navigate('Post', { postId: item.id })
}
/>
)}
keyExtractor={(item) => item.id.toString()}
ListEmptyComponent={() => {
if (this.state.loading) {
return <Text testID="loading-message">Loading</Text>;
}
return <Text testID="no-results">Sorry, no results found.</Text>;
}}
/>
</SafeAreaView>
);
}
}
export default PostList;
Renders error message if API throws error
Next, we want to handle the case where our API throws an error.
App/screens/__tests__/PostList.test.js
// ...
describe('PostList', () => {
// ...
test('render error message if error thrown from api', async () => {
fetch.mockRejectOnce(new Error('An error occurred.'));
const { getByTestId, toJSON, getByText } = render(<PostList />);
await waitForElement(() => {
return getByTestId('error-message');
});
expect(getByText('An error occurred.'));
});
});
To fix this failing test we add an element to the ListEmptyComponent
.
App/screens/PostList.js
// ...
class PostList extends React.Component {
// ...
render() {
return (
<SafeAreaView>
<FlatList
testID="post-list"
data={this.state.posts}
renderItem={({ item, index }) => (
<PostRow
item={item}
index={index}
onPress={() =>
this.props.navigation.navigate('Post', { postId: item.id })
}
/>
)}
keyExtractor={(item) => item.id.toString()}
ListEmptyComponent={() => {
if (this.state.loading) {
return <Text testID="loading-message">Loading</Text>;
}
if (this.state.error) {
return <Text testID="error-message">{this.state.error}</Text>;
}
return <Text testID="no-results">Sorry, no results found.</Text>;
}}
/>
</SafeAreaView>
);
}
}
export default PostList;
Post row is tappable
Finally, we want to confirm that each post row is tappable. This is one of the reasons I like react-native-testing-library
. It gives us the means to easily do simple tests like above but we can also use the same library to interact with our components.
App/screens/__tests__/PostList.test.js
// ...
describe('PostRow', () => {
test('is tappable', () => {
const onPress = jest.fn();
const { getByText } = render(
<PostRow index={0} item={{ title: 'Test' }} onPress={onPress} />
);
fireEvent.press(getByText('Test'));
expect(onPress).toHaveBeenCalled();
});
});
First we want to create a mock onPress function so that we can analyze it. We also render the PostRow
component with its required props.
We can then use the fireEvent
function from react-native-test-library
to simulate a press. Finally, we check that our jest mock function as been called.
And this test passes without any changes!
Summary
In summary, writing tests doesn't have to be complex. Using Jest + react-native-testing-library
can give you a lot of flexibility in writing integration tests without a bunch of overhead to manage.
Final Code
App/screens/PostList.js
import React from 'react';
import {
SafeAreaView,
Text,
TouchableOpacity,
StyleSheet,
FlatList,
View,
} from 'react-native';
import { api } from '../util/api';
const styles = StyleSheet.create({
row: {
paddingVertical: 8,
paddingHorizontal: 10,
},
});
export const PostRow = ({ item, index, onPress }) => (
<TouchableOpacity
testID={`post-row-${index}`}
style={styles.row}
onPress={onPress}
>
<Text>{item.title}</Text>
</TouchableOpacity>
);
class PostList extends React.Component {
state = {
posts: [],
loading: true,
error: null,
};
componentDidMount() {
this.getPosts();
}
getPosts = () => {
api('/posts')
.then((posts) => {
this.setState({ posts, loading: false, error: null });
})
.catch((error) => {
this.setState({ loading: false, error: error.message });
});
};
render() {
return (
<SafeAreaView>
<FlatList
testID="post-list"
data={this.state.posts}
renderItem={({ item, index }) => (
<PostRow
item={item}
index={index}
onPress={() =>
this.props.navigation.navigate('Post', { postId: item.id })
}
/>
)}
keyExtractor={(item) => item.id.toString()}
ListEmptyComponent={() => {
if (this.state.loading) {
return <Text testID="loading-message">Loading</Text>;
}
if (this.state.error) {
return <Text testID="error-message">{this.state.error}</Text>;
}
return <Text testID="no-results">Sorry, no results found.</Text>;
}}
/>
</SafeAreaView>
);
}
}
export default PostList;
App/screens/__tests__/PostList.test.js
import React from 'react';
import {
render,
waitForElement,
fireEvent,
} from 'react-native-testing-library';
import PostList, { PostRow } from '../PostList.js';
describe('PostList', () => {
test('renders a loading component initially', () => {
const { getByTestId } = render(<PostList />);
expect(getByTestId('loading-message'));
});
test('render message that no results found if empty array returned', async () => {
fetch.mockResponseOnce(JSON.stringify([]));
const { getByTestId } = render(<PostList />);
await waitForElement(() => {
return getByTestId('no-results');
});
expect(getByTestId('no-results'));
});
test('renders a list of posts', async () => {
fetch.mockResponseOnce(
JSON.stringify([
{ id: 1, title: '1' },
{ id: 2, title: '2' },
])
);
const { queryByTestId, getByTestId } = render(<PostList />);
expect(queryByTestId('post-row-0')).toBeNull();
await waitForElement(() => {
return queryByTestId('post-row-0');
});
expect(getByTestId('post-row-0'));
});
test('render error message if error thrown from api', async () => {
fetch.mockRejectOnce(new Error('An error occurred.'));
const { getByTestId, toJSON, getByText } = render(<PostList />);
await waitForElement(() => {
return getByTestId('error-message');
});
expect(getByText('An error occurred.'));
});
});
describe('PostRow', () => {
test('is tappable', () => {
const onPress = jest.fn();
const { getByText } = render(
<PostRow index={0} item={{ title: 'Test' }} onPress={onPress} />
);
fireEvent.press(getByText('Test'));
expect(onPress).toHaveBeenCalled();
});
});