Functional Testing using React testing library and Jest

React Testing Library makes functional testing of React components easier. It provides a virtual DOM where we can render components, provides methods to query the DOM for various elements, interact with those elements and make assertions on them. The objective of this post is to serve as a one-stop reference material for the most commonly used code patterns related to React testing library.

A) The basics of Testing library

Every test consists of three parts: render a component, query for an element and then make assertions on it. For example:

import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from './App';

test('Displays a heading', () => {
  render(<App />);
  const heading = screen.getByRole('heading', { 
    name: /hello world/i 
  });
  expect(heading).toBeInTheDocument();
});

Testing library has three packages which we use commonly: @testing-library/react for rendering elements to the DOM and querying those elements, @testing-library/jest-dom for additional Jest assertions (toBeInTheDocument) and @testing-library/user-event for firing events on the rendered elements.

In the above test, we render the App component and query for an element. The query is usually done by a role. In this case, it is a heading (role = heading). Additionally, the heading has text within it which equals the regular expression (/hello world/i). The /i stands for case-insensitive search. Once the element is found, we make a Jest assertion that is very intuitive.

B) Accessibility and Roles

React testing library wants to query elements by accessibility attributes. The philosophy is to read the rendered fragment just as a screen reader will. Most elements have a role. A button tag has a button role. Tags like h1, h2, etc have a heading role. For a complete set of roles, please refer to this link.

screen.getByRole('img', { name: /beach/i });

The above code gets an image (img tag) with an alt text containing the text ‘beach’. It is important to supply the name option and get only one element. Otherwise, the getByRole function will throw an exception and the test will fail. To get more than one element, we have to use the getAllByRole function. But for most scenarios, we will get only one element.

If you are not able to query by role, the most commonly used alternative is to get by text.

const textElement = screen.getByText(/price/i);
expect(textElement).toHaveTextContent('$1.25');

In the above code, we get some div or span which has a price text within it (static part). And we test if the price is $1.25 by checking the dynamic part of the text element. The toHaveTextContent is another Jest assertion that checks if an element contains a given text.

C) Querying alternatives

We have seen the getByRole function. It retrieves the element already rendered in the DOM. But if the element is not present in the DOM, then it will throw an exception. And the test will fail. To avoid that, Testing library provides another function queryByRole that returns null when the element is not rendered in the DOM. It is useful for writing negative tests.

const errorText = screen.queryByText(/email is required/i);
expect(errorText).not.toBeInTheDocument();

The above code queries for a text element. And expects that the element is not in the DOM. A common scenario for the above test is to ensure that there is no validation error when there is text in the input control.

Sometimes, an element is not immediately rendered in the DOM. But appears after some time because of an API call. In such a scenario, both getByRole and queryByRole function won’t retrieve the element. For this, we have to use yet another function findByRole.

test('Users component shows a list of users', async () => {
  render(<Users />);
  const users = await screen.findByRole('list');
  expect(users).toBeInTheDocument();
});

Here, we render the Users component. It retrieves a list of users that renders in the DOM after an API call. The findByRole function is an async function. So, the test function should have the async keyword. And the findByRole function has the await keyword.

The complete list of query functions are available in testing library docs.

D) More assertions

So far, we have used only one type of assertion: toBeInTheDocument. There are many more assertions that can be found in the ReadMe section of the jest-dom package.

In most forms, the submit button is in a disabled state initially. If there is user input, then we enable the submit button.

import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SomeForm from './SomeForm';

test('Button state in form', () => {
  render(<SomeForm />);
  const button = screen.getByRole('button', { name: /submit/i });
  expect(button).toBeDisabled();
  
  const emailText = screen.getByRole('textbox', { name: /email/i });
  userEvent.type(emailText, 'email@google.com');
  expect(button).toBeEnabled();

  userEvent.clear(emailText);
  expect(button).toBeDisabled();
});

In the above code, there are two more assertions: toBeDisabled and toBeEnabled. Their meaning is self-explanatory. But what does the code do?

It queries for a button with button text containing Submit. The initial state of the button should be disabled. Then we get a textbox with a label having the ’email’ text. When the user types some text into it, we test if the button is enabled. And then, when the user clears the text in the textbox, we check if the button reverts back to the disabled state.

In this example, we also make use of @testing-library/user-event package. This package provides functions to interact with the rendered elements. For example, in the above code, we type text into a textbox or clear the text in the textbox with the userEvent functions.

E) Handle animations

The most common type of animation is fade animation. With a fade animation, the element opacity changes from 0 to 1 when it appears on the screen. The reverse happens when it disappears from the screen. The below code tests for a popup when the user hovers over an element. It also tests for the disappearance of a popup when the user unhovers the element.

test('hover / unhover element', () => {
  render(<SomeItemWithPopoverEffects />);
  const tooltip = screen.queryByRole('tooltip');
  expect(tooltip).not.toBeInTheDocument();

  const item = screen.getByText(/important item/i);
  userEvent.hover(item);
  const tooltipAppears = screen.getByRole('tooltip');
  expect(tooltipAppears).toBeInTheDocument();

  userEvent.unhover(item);
  const tooltipDisappears = screen.queryByRole('tooltip');
  expect(tooltipDisappears).not.toBeInTheDocument();
});

First, we test if the tooltip is not in the DOM. Then, we hover over the item. And check if the tooltip appears in the DOM. After that, we unhover the item. And check if the tooltip disappears from the DOM. However, this test won’t work. The part that won’t work is the assertion that checks the disappearance of the tooltip from the DOM.

When the tooltip disappears, it disappears with an animation. So, the tooltip will hang in the DOM for 200ms and then disappears. It might be tempting for us to use the findByRole function.

const tooltipDisappears = await screen.findByRole('tooltip');
expect(tooltipDisappears).not.toBeInTheDocument();

Unfortunately, findByRole function throws an error if it can’t find the element in the DOM. So, the test will fail. To take care of this scenario, there is a new function – waitForElementToBeRemoved in testing library.

import { waitForElementToBeRemoved } from '@testing-library/react';
...
await waitForElementToBeRemoved(() => screen.queryByRole('tooltip'));

Please note that this is an async function and we have to await on it.

F) Handle API calls using Mock Service Worker

UI components interact with API to get data to render. For functional testing, we have to mock this API call. The best package to do API mocks is msw or “Mock service worker”.

Mock Service worker consists of setting up handlers that will process our incoming requests. Then, we configure a server to use these default handlers.

i) Create handlers using the rest helper object.

Handlers intercept API requests made to a real server and processes those requests by providing default mock data. An example handler is shown below.

import { rest } from "msw";

export const handlers = [
  rest.get("http://localhost:5000/orders", (req, res, ctx) => {
    return res(
      ctx.json([
        {
          orderId: "1000",
          date: "some valid date"
        }
      ])
    );
  }),
  rest.post("http://localhost:5000/order", (req, res, ctx) => {
    return res(ctx.json({ orderNumber: 1000 }));
  }),
];

There are two handlers: one for creating a new order and another for getting a list of orders. It can be anything depending on your app. In these handlers, we provide some mock data for the app to render it and for our tests to pass.

ii) Setup server

Setting up a server is easy. We just pass all the handlers to an utility function.

import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

iii) Modify the setup code for tests.

If you are using create-react-app for your React project, there is a setupTests.js file. Modify that file like so.

import { server } from "./mocks/server";

beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

We setup the server to listen to incoming requests before the test starts and close the server when all tests finish running. And after each test, we reset the handlers back to the default. This is because if one of our tests modify the handlers, we want to reset it back after the test ends.

Now that our mocks are set, our components will render the UI with mock data. We might want to modify the handler for one of our test. For example, if we want to test a server failure, we can write some code like so, within our test:

server.resetHandlers(
    rest.get("http://localhost:5000/orders", (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );

G) Handling Redux providers

In most projects, UI stores the data in global state or context. Let’s assume that our app keeps global state in Redux. This means that each of our test component should be wrapped within the Redux provider.

const ReduxProvider = ({ children }) => <Provider store={store}>{children}</Provider>;
render(<MyCompWithinReduxProvider />, { wrapper: ReduxProvider });

If we have to wrap all our test components within a provider, we can override the render function. The render function has two arguments: component to render in the DOM and options for the render function. The below code shows a custom render function which wraps every component with a redux provider.

const customRender = (ui, options) => {
  return render(
    <Provider store={store}>{ui}</Provider>,
    options,
  )
}

export default customRender;

Use this render function instead of the render function from testing library like so.

import render from './render';
...
render(<MyComp />);

React Testing Library has made testing very intuitive – render a component, query for an element in the DOM, make assertions on that element. Always search for an element by role. And use the name option to fetch a single element. Once you pass the initial learning curve, hopefully with the help of this article, you will find that writing functional tests using the testing library is fun.

Related Posts

One thought on “Functional Testing using React testing library and Jest

Leave a Reply

Your email address will not be published.