Table of contents
- What testing tools to use - Jest, Enzyme, React Testing Library
- React Testing Library Intro
- Basic React test
- adding tests
- Testing an element is not there
- Finding specific elements in the DOM with
data-testid
- Interacting with your components
- Typing into an input
- Submitting forms
- Get debugging help by outputting the HTML
- Do you need to run
expect()
around your assertions in React Testing Library? getBy
vsfindBy
vsqueryBy
- The
act()
function - Waiting for async actions to run
- Waiting for elements to be removed from the dom
- How to force a re-render
- Testing what happens when you unmount
- Tip: avoid using getByTestId...
This is an intro guide on some basic functionality within React Testing Library. Its aimed at those who know how to test in other frameworks. This was also written a few years ago - there are more modern ways of testing with RTL now. Check out the RTL docs for updated advice.
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()
})
data-testid
Finding specific elements in the DOM with 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.
expect()
around your assertions in React Testing Library?
Do you need to run 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 useawait
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).
act()
function
The 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 →Basic guide to testing in React with RTL