Guide to testing in React

Table of contents

For the last few years I've been mostly working with Vue for any frontend work, so that meant (mostly) testing with Vue Test Utils.

I've started to use React more lately... and it took quite a bit of Googling to figure out how to do things the 'react way'.

This is a guide to everything I've picked up so far. This is not intended to be a tutorial on what kinds of things to test, but more of a reference of how to use React Testing Library. Some of these points will be more like rough notes than a full drawn out blog post :)

What testing tools to use - Jest, Enzyme, React Testing Library

Vue Test Utils uses Jest, so that was the first one I tried to look into. Enzyme appears to have been popular in the past. But the more modern way now for most React apps seems to be with React Testing Library.

(If you disagree, let me know in the comments).

For that reason I'm going to focus on React Testing Library (RTL). And we will use Jest with RTL.

(I'm only concentrating on unit/integration testing - using something like Cypress is another option if you want some e2e tests.)

React Testing Library Intro

React Testing Library is not actually a test runner. You will still end up using something like Jest. (It can be configured to use something else).

It provides a nice API to write good quality tests. It helps discourage testing internal implementation details.

Basic React test

Let's create a quick react app in ./webdevetc:

npx create-react-app webdevetc;
cd webdevetc;

And replace App.js with:

import { useState } from 'react';

export default function App() {
  const [count, setCount] = useState(0)
  const handleIncrement = () => setCount(oldVal => oldVal + 1);
  const handleDecrement = () => setCount(oldVal => oldVal - 1);
  return (
    <>
      <h1 data-testid="AppCounterHeading">Counter: {count}</h1>
      <button 
         data-testid="AppIncButton" 
         onClick={handleIncrement}
       >Increment</button>
      <button 
         data-testid="AppDecButton" 
         onClick={handleDecrement}
       >Decrement</button>
    </>
  );
}

Run yarn start to check it out in your browser. Its your typical example you'll find in many React apps... click a button to increase the count...

adding tests

When you use Create React App it includes some testing dependancies. In case you are working with an existing app, you will need to install the following:

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

And make sure your package.json has a script of "test": "react-scripts test" (although you are more likely to just run it with jest).

Anyway, let's add our first test.

When we test with Vue Test Utils we mount a component and get a 'wrapper' component which we can query like the DOM.

With React Testing Library we can use screen, which is like a user agent that is using your component.

Some of the examples in this guide are not going to be great tests. They're listed here so you can see how you can write certin types of tests.

To begin with, lets test that it renders the initial 'Count: 0'

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

it('should render default heading with count of 0', async () => {
  render(<App />);
  expect(await screen.findAllByText("Counter: 0")).toHaveLength(1)
})

You can also get a bit closer to what you might be used to in the DOM and get the container element and call things such as querySelectorAll:

import { render } from '@testing-library/react';
import App from './App';

it('should render default heading with count of 0', async () => {
  const {container} = render(<App />);
  expect(await container.querySelectorAll('h1')).toHaveLength(1)
})

Although that is testing implementation details (what if it was changed to a <h2>...).

Another way to count elements is to run findAllByText() on the returned value of render():

import { render } from '@testing-library/react';
import App from './App';

it('should render default heading with count of 0', async () => {
  const wrapper = render(<App />);
  expect(await wrapper.findAllByText('Counter: 0')).toHaveLength(1)
})

Note: using wrapper like this is not recommended - I'm using it here as I am used to VTU, but its often called view or just destructured. If you use screen you don't need it at all, which leads to cleaner code.

But I think a nicer way is to call findByText() and then assert that it is in the dom document:

import { render } from '@testing-library/react';
import App from './App';

it('should render default heading with count of 0', async () => {
  const wrapper = render(<App />);
  expect(wrapper.getByText('Counter: 0')).toBeInTheDocument()
})

(Note: getByText will throw an error if it cannot find anything).

Testing an element is not there

You can check if an element does not exist by using queryBy:

import { render } from '@testing-library/react';
import App from './App';

it('should render default heading with count of 0', async () => {
  const wrapper = render(<App />);
  expect(wrapper.queryByText('Counter: 1')).not.toBeInTheDocument()
  expect(wrapper.queryByText('Counter: 1')).toBeNull()
})

Finding specific elements in the DOM with data-testid

One thing I really like to do is not query by things like <h1>, or a class name etc.

Using data-testid attributes (such as <h1 data-testid="AppCounterHeading">{count}</h1>) that are used just for testing have a few advantages:

  • You can change implementation details such as the tag (to <h2>), change the classes etc
  • If anyone edits the file it is very obvious what data-testid is for
  • The tests are clean
  • You can remove them when building for production

I do think it is never a good idea to add code specific to testing into your actual code... but its a good compromise when testing FE components.

import { render } from '@testing-library/react';
import App from './App';

it('should render default heading with count of 0', async () => {
  const wrapper = render(<App />);
  expect(await wrapper.findAllByTestId('AppCounterHeading')).toHaveLength(1)
})

If you do not like data-testid, you can change it via configure({testIdAttribute: 'data-my-test-attribute'})

Interacting with your components

So far we've done some very basic tests (that aren't really going to add much value).

Let's check if we can increment the counter.

We can use the fireEvent helper:

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

it('should be able to increment the counter', async () => {
  const wrapper = render(<App />);
  expect(await wrapper.findByText('Counter: 0')).toBeInTheDocument()

  const incButton = await wrapper.findByTestId('AppIncButton')
  fireEvent.click(incButton)

  expect(await wrapper.findByText('Counter: 1')).toBeInTheDocument()
})

Or, confusingly, there is another way to do this with userEvent (and using userEvent is the preferred way to trigger events details here):

import { render } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
// or { click } from '@testing-library/user-event'
import App from './App';

it('should be able to increment the counter', async () => {
  const wrapper = render(<App />);
  expect(await wrapper.findByText('Counter: 0')).toBeInTheDocument()

  const incButton = await wrapper.findByTestId('AppIncButton')
  userEvent.click(incButton);

  expect(await wrapper.findByText('Counter: 1')).toBeInTheDocument()
})

Under the hood userEvent uses the fireEvent.

If you're interested, take a look at the source code to learn more.

Typing into an input

Let's say you want to type 'apples' into a search box.

const searchInput = screen.getByTestId('AppSearchInput');
userEvent.type(searchInput, 'apples');

Submitting forms

const submitBtn = screen.getByRole('button', { name: "Search" });
userEvent.click(submitBtn);

Get debugging help by outputting the HTML

Use screen.debug() to log the HTML DOM to your console.

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

it('should be able to increment the counter', async () => {
  render(<App />);
  screen.debug()
})

You can also pass in a param to output a specific element, for example:

 const btn = screen.findByTestId('AppSubmitButton')
 screen.debug(btn)

Or you can add an inline snapshot. For this to work you will need to add prettier (yarn add -D prettier).

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

it('should be able to increment the counter', async () => {
  const {container} = render(<App />);
  expect(container).toMatchInlineSnapshot()
})

Once you run the test for the first time, jest will update toMatchInlineSnapshot() with the HTML DOM. When you run the test again, if the output is different the test will fail. Run jest -u to update, or press u if in interactive mode.

I am not a fan of snapshots. They seem useful and easy to write tests, but when they fail its either not clear why the test failed (as there is so much output), or people will just update the snapshots without checking if the new snapshot is correct. Avoid using these.

Do you need to run expect() around your assertions in React Testing Library?

The getBy functions will throw an error if they're not found. (Similar to wrapper.get() in Vue Test Utils). So you don't really need to run it through expect(screen.getByText("something")).toBeInTheDocument()

(queryBy will return null if it isn't found - similar to wrapper.find() in Vue Test Utils).

So these two are both going to give you a failed test if it cannot find an element with 'something' as the text.

screen.getByText('something')
expect(screen.getByText('something')).toBeInTheDocument()

getBy vs findBy vs queryBy

There are 3 ways to find elements in your DOM.

  • Use getBy if you are expecting an element to be there right now, and you want to find it. (synchronous)
  • Use findBy if you are expecting an element to be there soon - it will retry to find it, and you must use await with it (asynchronous)
  • Use queryBy if you are not expecting an element to be there (expect(screen.queryByText('something')).toBeNull())

Note - all 3 of the above will throw an error if more than one element is found.

If you want to get an array of all elements that match, then use getAllBy (throws an error if none found), findAllBy (throws an error if none found), queryAllBy (returns an array - maybe empty).

The act() function

This is not part of React Testing Library. I am not a fan of using act() but maybe that is because I am not used to it (we don't need this in Vue).

This is React's own act() function (specs here).

// ...
act(() => {
    button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});
// ...

If you are making use of render and fireEvent, you don't need act as those function call act.

Waiting for async actions to run

When in need to wait for any period of time you can use waitFor, to wait for your expectations to pass. Here's a simple example:

    await waitFor(() => expect(someMockAPI).toHaveBeenCalledTimes(1))

waitFor may run the callback a number of times until the timeout is reached. Note that the number of calls is constrained by the timeout and interval options.

The default interval is 50ms. However it will run your callback immediately before starting the intervals.

The default timeout is 1000ms.

Waiting for elements to be removed from the dom

You can use waitForElementToBeRemoved() (which is a wrapper around waitFor())

// ...
await waitForElementToBeRemoved(screen.queryByText("Loading your profile..."));
// ...

The same default interval/timeouts from waitFor apply here too.

more coming soon

How to force a re-render

Hopefully you don't have to do this often, but maybe you want to re-render a component.

import {render} from '@testing-library/react'

const {rerender} = render(<SomeComponent howMany={1} />)

// change the props:
rerender(<SomeComponent howMany={2} />)

Testing what happens when you unmount

import {render} from '@testing-library/react'

const {container, unmount} = render(<YourComponent />)
unmount()
// your component has been unmounted and now: container.innerHTML === ''

Tip: avoid using getByTestId...

Although I like using it, and have used it a lot in this guide - most users would say we should avoid getByTestId (or queryByTestId/getByTestId) and instead aim for things like getByRole().

Using getByRole() is a great example actually - because using it will encourage your HTML markup to be semantically correct.

Comments Guide to testing in React