If you had invested heavily in React component testing about three or four years ago, you probably used Jest and Enzyme. Jest has become the de-facto test runner for React applications, and Enzyme was a component mounting library that used JSDom or another globally scoped DOM API that emulates a browser for fullly mounted components and children.
However, Enzyme development has been stalled for a few years, and as of React 18 it doesn't work with a current testing adapter. According to this blog entry, written by the author of the unofficial React 17 Enzyme adapter, the Enzyme team is not working on the PR for an official React 17 adapter, nor is it straightforward to get a React 18 adapter built. It appears the key concern with React 18 is the async rendering feature, which would break a lot of the Enzyme library and adapter code.
If you're committed to whitebox testing your React components, debugging issues with your component's lifecycle, rendering, events, and the like, you likely will need to move to a different framework. Thankfully, a new one exists, and has become the de-facto solution for React applications: the DOM Testing Library and its React Testing Library API.
What is the DOM Testing Library and why use it?
The DOM Testing Library is the core of the suite of testing APIs at testing-library.com. The tools for React testing extend this library and are documented at the react-testing-library area of the site.
This team of well over a hundred contributors created a comprehensive testing library, based on the DOM API, that allows you to test as if your tests acted as a browser user.
Think of your tests as if you are interacting with the DOM, and therefore you keepng the internals of your components at arm's length. Rather than digging in to the state of a component, your tests look into the contents of the screen. Rather than calling an event method directly, tests trigger it via an event, like a click.
Switching to the React/DOM Testing Library
NOTE: From now on I'm going to call React Testing Library "RTL".
Because RTL isn't a drop-in replacement for Enzyme, you'll have to come up with a migration strategy. Likely, you'll end up doing a few things:
- replacing / upgrading your application shell if running
create-react-app
- building new RTL tests and learning new strategies for existing test approaches
- eventually removing Enzyme once all tests are ported to RTL
This isn't easy work, and it will take a bit of time. However, in the end it should make for more concise tests, and set you up for running React 18 and taking advantage of that sweet, sweet async rendering performance.
Installing RTL
If you're a Create React App
shell user, you can upgrade to the latest create-react-app shell to get the latest Jest API, and then install your dependencies. There are basically two options here:
- see the instructions, or
- Install a new empty app with
npx create-react-app
and move your stuff inside of it, wrestling with missing dependencies, yeah, it's a mess. Finally, move it all back and commit your changes.
Try #1 first, then #2 if you're stuck. If your application is really, really old, there is no way but through it, and gutting it out with a new shell is sometimes the better way to roll. Oh how I miss Maven and Java sometimes for predictable build issues.
Wait… Did I just write that? Yes.
To install the React Testing library you'll need to use npm/yarn
to add:
@testing-library/jest-dom
– this is the DOM Testing Library, as called from Jest browser-based tests@testing-library/react
– the React Testing Library@testing-library/user-event
– a library to trigger user events (click, scroll, type, etc)
the src/setupTests.js
Jest config file will need to include:
import '@testing-library/jest-dom';
and whatever else you'll be using. You can keep Enzyme installed alongside of RTL. The same npm test
command runs Jest and now Jest just has access to the RTL code as well.
Technique #1: Snapshot tests with RTL
OK, so you've generated snapshots of your component trees to catch regressions in your components. The Enzyme way generally looks like this (assuming we're using Enzyme with the static component rendering approach here):
import renderer from 'react-test-renderer'; // alternatively we could use enzyme-to-json import Letter from '../Letter'; describe('<Letter />', () => { const props = { addLetter: jest.fn(), character: 'C', index: 3 }; it('matches snapshot', () => { const letter = renderer .create(<Letter {...props} />) .toJSON(); expect(letter).toMatchSnapshot(); }); });
Basically you take the rendered tree, convert it to JSON, then compare it to the JEST snapshot you've already stored.
News flash, your new test snapshots don't have exactly the same format and include some wrapper nodes. So prepare to review and replace those snapshots.
Here's the RTL way:
import { render } from '@testing-library/react'; import Letter from '../Letter'; describe('<Letter />', () => { const props = { addLetter: jest.fn(), character: 'C', index: 3 }; it('renders a letter C as an enabled, unused letter', () => { const {container} = render(<Letter {...props} disabled={false}/>); expect(container).toMatchSnapshot(); }); ... });
And that's our first difference: we use the @testing-library/react
import and its render
method to mount our React component in JEST's browser DOM, which is then used by the DOM testing library and RTL. You'll note the destructuring here of the results of the render method. There are a ton of utilities for locating DOM nodes, and one of them exposes toMatchSnapshot
. Run your JEST test and review the new snapshot, replacing the old
Enzyme-based one if it looks equivalent overall.
Technique #2: Locating and comparing DOM content
Next up, we'll see how we check the values within our React component content. Here's an Enzyme test that mounts a component with its props and checks whether or not inner content is set:
import {shallow} from 'enzyme'; import StatusBar from '../StatusBar'; describe('<StatusBar />', () => { it('should only display High Score when greater than 0', () => { const statusBar = shallow( <StatusBar mode='standby' highScore={20} wordCount={2}/>); expect(statusBar.find('#highScore').prop('hidden')).toBe(false); expect(statusBar.find('#highScore').text()).toContain('20'); }); ... });
In this example, we're mounting a <StatusBar>
component we've written, and if the game mode is standby
, we only show the high score, not a word count (we throw it in there to make sure we don't accidentally render it).
We're using the shallow
method of Enzyme, which mounts the component but not any of its children.
Here is the same test in RTL:
import { render, screen } from '@testing-library/react'; import StatusBar from '../StatusBar'; describe('<StatusBar />', () => { it('should display High Score when greater than 0', () => { render(<StatusBar mode='standby' highScore={20} wordCount={2}/>); expect(screen.getByText(/High Score/)).toBeVisible(); expect(screen.getByText(/High Score/)).toHaveTextContent('20'); }); ... });
We still use the render
method in RTL. Now, however, we also use the screen
object which gives us access to the DOM via various searching matchers. The getByText
method above searches for the words 'High Score' in the component. The matcher functions toBeVisible()
and toHaveTextContent()
are provided from the testing library.
N.B. – some subtleties and quirks
There are some quirks with RTL that you need to be aware of. RTL is a bit challenging to learn at first, primarily because you need to internalize the Guiding Principles and learn about the key testing priorities that make sure you write code as if it was interacting with the DOM like a user.
By following the guidelines of RTL and the DOM Testing Library, you should begin to create more accessible applications. The more accessible your objects are (can a screen reader find a button by its role, for example), the better they will serve everyone.
A key thing to pick up right up front is the variation of screen
locator functions (known as queries):
getByXXXXXX
– expects to find exactly one of the item in question, and throws an error if it is not located.getAllByXXXX
– expects to find 1 or more of the item in question, and will return an array or throw an error.queryByXXXX
– returns null if not found, otherwise likegetByXXXX
.queryAllByXXXX
– returns an empty array if none are found, otherwise an array full of found itemsfindByXXXX
– throws an error unless exactly one item is found, and can useawait
andwaitFor
to find and retry asynchronouslyfindAllByXXXX
– similar toqueryAllBy
but can useawait
andwaitFor
to find and retry asynchronously
By now, you probably want more examples.
Technique #3: Search by text fragment regex
You can use regex patterns to find things (omitting the boilerplate):
// TODO - figure out better pattern it('matches snapshot with 3 words', () => { const { asFragment } = render(<WordList wordList={['Foo', 'Bar', 'Baz']} />); expect(asFragment()).toMatchSnapshot(); expect(screen.getByText(/Foo/)).toBeInTheDocument(); expect(screen.getByText(/Bar/)).toBeInTheDocument(); expect(screen.getByText(/Baz/)).toBeInTheDocument(); expect(screen.getAllByTestId('entered-word')).toHaveLength(3); });
Technique #4: Searching by test ids
Sometimes it's hard to locate things unless you add an ID specifically for testing. Unlike DOM IDs, these can be non-unique, and so they are just used in testing tools to find things. The attribute to set is data-testid
:
// in your react component: export default function WordList ({ wordList }) { return ( <div className="wordContainer wordList"> { wordList.map((word, index) => { return ( <div key={'word-' + index} data-testid="entered-word"> <i className="bi bi-plus-lg"></i> {word} </div> ); }) } </div> ); }
Now you can locate all of the items by that testid:
it('matches snapshot with 3 words', () => { const { asFragment } = render(<WordList wordList={['Foo', 'Bar', 'Baz']} />); ... expect(screen.getAllByTestId('entered-word')).toHaveLength(3); });
…and so on…
Technique #5: Doing things later with waitFor and async/await
Suppose you are testing events in a game, such as starting/ending the game by clicking buttons in one of the rendered components. RTL can send events via the User Event library:
import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event' import Words from '../Words'; it('should start in game mode and render WordBuilder and WordList', async () => { render(<Words/>); expect(screen.getByText(/Words for Nerds/)).toBeInTheDocument(); expect(screen.getAllByRole('button')).toBeDefined(); expect(screen.queryByText(/START/)).toBeNull(); await waitFor(() => { expect(screen.getByText(/END GAME/)).toBeInTheDocument() }); });
We're doing a few things here:
- Using the
getAllByRole
function to make sure our buttons are defined - Clicking the button labeled
START
to begin a game - using
await
onwaitFor
to poll the component tree to watch theEND GAME
button appear – meaning we're in the game. - Also note the
async
prefix to ourit
test function – that enables us to useawait
and thewaitFor
function.
Wrap-up
Hopefully this post got you interested in the React Testing Library as a way to move away from Enzyme and toward supporting React 18 and async rendering. I've put together a sample library based on my React training course – sample-rtl-approaches that I pulled from for this article.
Check out Kent C. Dodds' react-testing-library-examples
samples. He is an author of the DOM Testing Library / RTL projects along with a talented team of developers and these are examples he wrote that run in CodeSandbox – see them live here.
And of course, the project is available at testing-library.com and on GitHub at github.com/testing-library/.