Technology
7 minute read

Increase Code Maintainability with React Integration Testing

Anton is a full-stack developer with a strong technical background. He specializes in JavaScript and he’s a fan of test-driven development.

Integration tests are a sweet spot between cost and value of tests. Writing integration tests for a React app with the help of react-testing-library instead of or in addition to component unit tests can increase code maintainability without impairing development speed.

In case you’d like to get a head start before we proceed, you can see an example of how to use react-testing-library for React app integration tests here.

Why Invest in Integration Testing?

"Integration tests strike a great balance on the trade-offs between confidence and speed/expense. This is why it's advisable to spend most (not all, mind you) of your effort there."
– Kent C. Dodds in Write tests. Not too many. Mostly integration.

It is a common practice to write unit tests for React components, often using a popular library for testing React “enzymes”; specifically, its “shallow” method. This approach allows us to test components in isolation from the rest of the app. However, since writing React apps is all about composing components, unit tests alone do not ensure that the app is bug-free.

For example, changing a component’s accepted props and updating its associated unit tests may result in all tests passing while the app may still be broken if another component was not updated accordingly.

Integration tests can help preserve peace of mind while making changes to a React app, as they ensure that the composition of components results in the desired UX.

Requirements for React App Integration Tests

Here are some of the things React developers want to do when writing integration tests:

  • Test application use-cases from the user’s perspective. Users access information on a web page and interact with available controls.
  • Mock API calls to not depend on API availability and state for passing/failing tests.
  • Mock browser APIs (for example, local storage) since they simply do not exist in test environment.
  • Assert on React DOM state (browser DOM or a native mobile environment).

Now, for some things we should try to avoid when writing React app integration tests:

  • Test implementation details. Implementation changes should only break a test if they indeed introduced a bug.
  • Mock too much. We want to test how all the parts of the app are working together.
  • Shallow render. We want to test the composition of all the components in the app down to the smallest component.

Why Choose React-testing-library?

The aforementioned requirements make react-testing-library a great choice, as its main guiding principle is to allow for React components to be tested in a way that resembles how they are used by an actual human.

The library, along with its optional companion libraries, allows us to write tests that interact with DOM and assert on its state.

Sample App Setup

The app for which we are going to write sample integration tests implements a simple scenario:

  • The user enters a GitHub username.
  • The app displays a list of public repositories associated with the entered username.

How the above functionality is implemented should be irrelevant from an integration testing perspective. However, to keep close to real-world applications, the app follows common React patterns, hence the app:

  • Is a single-page app (SPA).
  • Makes API requests.
  • Has global state management.
  • Supports internationalization.
  • Utilizes a React component library.

The source code for the app implementation can be found here.

Writing Integration Tests

Installing dependencies

With yarn:

yarn add --dev jest @testing-library/react @testing-library/user-event jest-dom nock

Or with npm:

npm i -D jest @testing-library/react @testing-library/user-event jest-dom nock

Creating an Integration Test Suite File

We will create a file named viewGitHubRepositoriesByUsername.spec.js file in the ./test folder of our application. Jest will automatically pick it up.

Importing Dependencies in the Test File

import React from 'react'; // so that we can use JSX syntax
import {
 render,
 cleanup,
 waitForElement
} from '@testing-library/react'; // testing helpers
import userEvent from '@testing-library/user-event' // testing helpers for imitating user events
import 'jest-dom/extend-expect'; // to extend Jest's expect with DOM assertions
import nock from 'nock'; // to mock github API
import {
 FAKE_USERNAME_WITH_REPOS,
 FAKE_USERNAME_WITHOUT_REPOS,
 FAKE_BAD_USERNAME,
 REPOS_LIST
} from './fixtures/github'; // test data to use in a mock API
import './helpers/initTestLocalization'; // to configure i18n for tests
import App from '../App'; // the app that we are going to test

Setting up The Test Suite

describe('view GitHub repositories by username', () => {
 beforeAll(() => {
   nock('https://api.github.com')
     .persist()
     .get(`/users/${FAKE_USERNAME_WITH_REPOS}/repos`)
     .query(true)
     .reply(200, REPOS_LIST);
 });

 afterEach(cleanup);

 describe('when GitHub user has public repositories', () => {
   it('user can view the list of public repositories for entered GitHub username', async () => {
     // arrange
     // act
     // assert
   });
 });


 describe('when GitHub user has no public repositories', () => {
   it('user is presented with a message that there are no public repositories for entered GitHub username', async () => {
     // arrange
     // act
     // assert
   });
 });

 describe('when GitHub user does not exist', () => {
   it('user is presented with an error message', async () => {
     // arrange
     // act
     // assert
   });
 });
});

Notes:

  • Prior to all tests, mock the GitHub API to return a list of repositories when called with a specific username.
  • After each test, clean the test React DOM so that each test starts from a clean spot.
  • describe blocks specify the integration test use case and the flow variations.
  • The flow variations we are testing are:
    • User enters a valid username that has associated public GitHub repositories.
    • User enters a valid username that has no associated public GitHub repositories.
    • User enters a username that does not exist on GitHub.
  • it blocks use async callback as the use case they are testing has asynchronous step in it.

Writing the First Flow Test

First, the app needs to be rendered.

 const { getByText, getByPlaceholderText, queryByText } = render(<App />);

The render method imported from the @testing-library/react module renders the app in the test React DOM and returns DOM queries bound to the rendered app container. These queries are used to locate DOM elements to interact with and to assert on.

Now, as the first step of the flow under test, the user is presented with a username field and types a username string into it.

 userEvent.type(getByPlaceholderText('userSelection.usernamePlaceholder'), FAKE_USERNAME_WITH_REPOS);

The userEvent helper from imported @testing-library/user-event module has a type method that imitates the behavior of the user when they type text into a text field. It accepts two parameters: the DOM element that accepts the input and the string that the user types.

Users usually find DOM elements by the text associated with them. In the case of input, it is either label text or placeholder text. The getByPlaceholderText query method returned earlier from render allows us to find the DOM element by placeholder text.

Please note that since the text itself is often likely to change, it is best to not rely on actual localization values and instead configure the localization module to return a localization item key as its value.

For example, when “en-US” localization would normally return Enter GitHub username as the value for the userSelection.usernamePlaceholder key, in tests, we want it to return userSelection.usernamePlaceholder.

When the user types text into a field, they should see the text field value updated.

expect(getByPlaceholderText('userSelection.usernamePlaceholder')).toHaveAttribute('value', FAKE_USERNAME_WITH_REPOS);

Next in the flow, the user clicks on the submit button and expects to see the list of repositories.

 userEvent.click(getByText('userSelection.submitButtonText').closest('button'));
 getByText('repositories.header');

The userEvent.click method imitates the user clicking on a DOM element, while getByText query finds a DOM element by the text it contains. The closest modifier ensures that we select the element of the right kind.

Note: In integration tests, steps often serve both act and assert roles. For example, we assert that the user can click a button by clicking it.

In the previous step, we asserted that the user sees the repositories list section of the app. Now, we need to assert that since fetching the list of repositories from GitHub may take some time, the user sees an indication that the fetching is in progress. We also want to make sure that the app does not tell the user that there are no repositories associated with the entered username while the repositories list is still being fetched.

 getByText('repositories.loadingText');
 expect(queryByText('repositories.empty')).toBeNull();

Note that the getBy query prefix is used to assert that the DOM element can be found and the queryBy query prefix is useful for the opposite assertion. Also, queryBy does not return an error if no element is found.

Next, we want to make sure that, eventually, the app finishes fetching repositories and displays them to the user.

 await waitForElement(() => REPOS_LIST.reduce((elementsToWaitFor, repository) => {
   elementsToWaitFor.push(getByText(repository.name));
   elementsToWaitFor.push(getByText(repository.description));
   return elementsToWaitFor;
 }, []));

The waitForElement asynchronous method is used to wait for a DOM update that will render the assertion provided as method parameter true. In this case, we assert that the app displays the name and description for every repository returned by the mocked GitHub API.

Finally, the app should no longer display an indicator that repositories are being fetched and it should not display an error message.

 expect(queryByText('repositories.loadingText')).toBeNull();
 expect(queryByText('repositories.error')).toBeNull();

Our resulting React integration test looks like this:

it('user can view the list of public repositories for entered GitHub username', async () => {
     const { getByText, getByPlaceholderText, queryByText } = render(<App />);
     userEvent.type(getByPlaceholderText('userSelection.usernamePlaceholder'), FAKE_USERNAME_WITH_REPOS);  expect(getByPlaceholderText('userSelection.usernamePlaceholder')).toHaveAttribute('value', FAKE_USERNAME_WITH_REPOS);
     userEvent.click(getByText('userSelection.submitButtonText').closest('button'));
     getByText('repositories.header');
     getByText('repositories.loadingText');
     expect(queryByText('repositories.empty')).toBeNull();
     await waitForElement(() => REPOS_LIST.reduce((elementsToWaitFor, repository) => {
       elementsToWaitFor.push(getByText(repository.name));
       elementsToWaitFor.push(getByText(repository.description));
       return elementsToWaitFor;
     }, []));
     expect(queryByText('repositories.loadingText')).toBeNull();
     expect(queryByText('repositories.error')).toBeNull();
});

Alternate Flow Tests

When the user enters a GitHub username with no associated public repositories, the app displays an appropriate message.

 describe('when GitHub user has no public repositories', () => {
   it('user is presented with a message that there are no public repositories for entered GitHub username', async () => {
     const { getByText, getByPlaceholderText, queryByText } = render(<App />);
     userEvent.type(getByPlaceholderText('userSelection.usernamePlaceholder'), FAKE_USERNAME_WITHOUT_REPOS);     expect(getByPlaceholderText('userSelection.usernamePlaceholder')).toHaveAttribute('value', FAKE_USERNAME_WITHOUT_REPOS);
     userEvent.click(getByText('userSelection.submitButtonText').closest('button'));
     getByText('repositories.header');
     getByText('repositories.loadingText');
     expect(queryByText('repositories.empty')).toBeNull();
     await waitForElement(() => getByText('repositories.empty'));
     expect(queryByText('repositories.error')).toBeNull();
   });
 });

When user enters a GitHub username that does not exist, the app displays an error message.

 describe('when GitHub user does not exist', () => {
   it('user is presented with an error message', async () => {
     const { getByText, getByPlaceholderText, queryByText } = render(<App />);
     userEvent.type(getByPlaceholderText('userSelection.usernamePlaceholder'), FAKE_BAD_USERNAME);     expect(getByPlaceholderText('userSelection.usernamePlaceholder')).toHaveAttribute('value', FAKE_BAD_USERNAME);
     userEvent.click(getByText('userSelection.submitButtonText').closest('button'));
     getByText('repositories.header');
     getByText('repositories.loadingText');
     expect(queryByText('repositories.empty')).toBeNull();
     await waitForElement(() => getByText('repositories.error'));
     expect(queryByText('repositories.empty')).toBeNull();
   });
 });

Why React Integration Tests Rock

Integration testing truly offers a sweet spot for React applications. These tests help catch bugs and use the TDD approach while, at the same time, they do not require maintenance when implementation changes.

React-testing-library, showcased in this article, is a great tool for writing React integration tests, as it allows you to interact with the app as the user does and validate app state and behavior from the user’s perspective.

Hopefully, the examples provided here will help you start writing integration tests on new and existing React projects. The full sample code that includes the app implementation can be found at my GitHub.

Understanding the basics

What is a React test?

Its an automated test of a user interface that is written with the React library.

What is integration testing in software testing?

Integration testing is a variant of automated testing aimed to ensure that separate components of the system are working together to fulfill the user’s goals.

What is the difference between unit testing and integration testing?

Integration tests ensure that components function together while unit tests ensure that each component functions on its own, but this does not guarantee that there are no issues in components integration.

What is end-to-end testing?

End-to-end testing is a variant of automated testing aimed to ensure that all of the systems are working together to fulfill the user’s goals.

Why should you use React?

It’s a great declarative, component-based library for building user interfaces.