Component Testing with React Native Testing Library

Author

Spencer Carli

Developer, cat dad, and devout pizza lover. Teaching at React Native School and building apps with Handlebar Labs.

Last Updated: August 14, 2019

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.

Error Message

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>
);

// ...

Success Message

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'));
  });
});

Error 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;

Success Message

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.

Error Message

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;

Success Message

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.'));
  });
});

Error Message

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;

Success Message

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.

Success Message

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();
  });
});

Additional Resources

React Native School Logo

React Native School

Want to further level up as a React Native developer? Join React Native School! You'll get access to all of our classes and our private Slack community.

Join the email list to be notified of all new lessons and classes!