Two weeks ago, I wrote
a new library! Iโve
been thinking about it for a while. But two weeks ago I started getting pretty
serious about it:
Read on to get an idea of what I mean by โdamaging practices.โ
react-testing-library
Simple and complete React DOM testing utilities that encourage good testing
practices.
The problem
You want to write maintainable tests for your React components. As a part of
this goal, you want your tests to avoid including implementation details of your
components and rather focus on making your tests give you the confidence for
which they are intended. As part of this, you want your testbase to be
maintainable in the long run so refactors of your components (changes to
implementation but not functionality) donโt break your tests and slow you and
your team down.
This solution
The react-testing-library
is a very light-weight solution for testing React
components. It provides light utility functions on top of react-dom
and
react-dom/test-utils
, in a way that encourages better testing practices. Its
primary guiding principle is:
So rather than dealing with instances of rendered react components, your tests
will work with actual DOM nodes. The utilities this library provides facilitate
querying the DOM in the same way the user would. Finding form elements by their
label text (just like a user would), finding links and buttons by their text
(like a user would). It also exposes a recommended way to find elements by a
data-testid
as an โescape hatchโ for elements where the text content and label
do not make sense or is not practical.
This library encourages your applications to be more accessible and allows you
to get your tests closer to using your components the way a user will, which
allows your tests to give you more confidence that your application will work
when a real user uses it.
This library is a replacement for enzyme. While you
can follow these guidelines using enzyme itself, enforcing this is harder
because of all the extra utilities that enzyme provides (utilities which
facilitate testing implementation details). Read more about this in
the FAQ.
Also, while the React Testing Library is intended for react-dom, you can use
React Native Testing Library
which has a very similar API.
What this library is not:
- A test runner or framework
- Specific to a testing framework (though we recommend Jest as our preference,
the library works with any framework, and even
in codesandbox!)
Basic Example
// hidden-message.js
import * as React from 'react'
// NOTE: React Testing Library works with React Hooks _and_ classes just as well
// and your tests will be the same however you write your components.
function HiddenMessage({children}) {
const [showMessage, setShowMessage] = React.useState(false)
return (
<div>
<label htmlFor="toggle">Show Message</label>
<input
id="toggle"
type="checkbox"
onChange={e => setShowMessage(e.target.checked)}
checked={showMessage}
/>
{showMessage ? children : null}
</div>
)
}
export default HiddenMessage
// __tests__/hidden-message.js
// These imports are something you'd normally configure Jest to import for you automatically.
// Learn more in the setup docs: https://testing-library.com/docs/react-testing-library/setup#skipping-auto-cleanup
import '@testing-library/jest-dom/extend-expect'
// NOTE: jest-dom adds handy assertions to Jest and is recommended, but not required
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import HiddenMessage from '../hidden-message'
test('shows the children when the checkbox is checked', () => {
const testMessage = 'Test Message'
render(<HiddenMessage>{testMessage}</HiddenMessage>)
// query* functions will return the element or null if it cannot be found
// get* functions will return the element or throw an error if it cannot be found
expect(screen.queryByText(testMessage)).toBeNull()
// the queries can accept a regex to make your selectors more resilient to content tweaks and changes.
userEvent.click(screen.getByLabelText(/show/i))
// .toBeInTheDocument() is an assertion that comes from jest-dom
// otherwise you could use .toBeDefined()
expect(screen.getByText(testMessage)).toBeInTheDocument()
})
Practical Example
// login.js
import * as React from 'react'
function Login() {
const [state, setState] = React.useReducer((s, a) => ({...s, ...a}), {
resolved: false,
loading: false,
error: null,
})
function handleSubmit(event) {
event.preventDefault()
const {usernameInput, passwordInput} = event.target.elements
setState({loading: true, resolved: false, error: null})
window
.fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: usernameInput.value,
password: passwordInput.value,
}),
})
.then(r => r.json())
.then(
user => {
setState({loading: false, resolved: true, error: null})
window.localStorage.setItem('token', user.token)
},
error => {
setState({loading: false, resolved: false, error: error.message})
},
)
}
return (
<div>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="usernameInput">Username</label>
<input id="usernameInput" />
</div>
<div>
<label htmlFor="passwordInput">Password</label>
<input id="passwordInput" type="password" />
</div>
<button type="submit">Submit{state.loading ? '...' : null}</button>
</form>
{state.error ? <div role="alert">{state.error.message}</div> : null}
{state.resolved ? (
<div role="alert">Congrats! You're signed in!</div>
) : null}
</div>
)
}
export default Login
// __tests__/login.js
// again, these first two imports are something you'd normally handle in
// your testing framework configuration rather than importing them in every file.
import '@testing-library/jest-dom/extend-expect'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import Login from '../login'
test('allows the user to login successfully', async () => {
// mock out window.fetch for the test
const fakeUserResponse = {token: 'fake_user_token'}
jest.spyOn(window, 'fetch').mockImplementationOnce(() => {
return Promise.resolve({
json: () => Promise.resolve(fakeUserResponse),
})
})
render(<Login />)
// fill out the form
userEvent.type(screen.getByLabelText(/username/i), 'chuck')
userEvent.type(screen.getByLabelText(/password/i), 'norris')
userEvent.click(screen.getByText(/submit/i))
// just like a manual tester, we'll instruct our test to wait for the alert
// to show up before continuing with our assertions.
const alert = await screen.findByRole('alert')
// .toHaveTextContent() comes from jest-dom's assertions
// otherwise you could use expect(alert.textContent).toMatch(/congrats/i)
// but jest-dom will give you better error messages which is why it's recommended
expect(alert).toHaveTextContent(/congrats/i)
expect(window.localStorage.getItem('token')).toEqual(fakeUserResponse.token)
})
The most important takeaway from this example is:
The test is written in such a way that resembles how the user is using your
application.
Letโs explore this furtherโฆ
Letโs say we have a GreetingFetcher
component that fetches a greeting for a
user. It might render some HTML like this:
<div>
<label for="name-input">Name</label>
<input id="name-input" />
<button>Load Greeting</button>
<div data-testid="greeting-text" />
</div>
So the functionality is: Set the name, click the โLoad Greetingโ button, and a
server request is made to load greeting text with that name.
In your test youโll need to find the <input />
so you can set its value
to
something. Conventional wisdom suggests you could use the id
property in a CSS
selector: #name-input
. But is that what the user does to find that input?
Definitely not! They look at the screen and find the input with the label โNameโ
and fill that in. So thatโs what our test is doing with getByLabelText
. It
gets the form control based on its label.
Often in tests using enzyme, to find the โLoad Greetingโ button you might use a
CSS selector or even find by component displayName
or the component
constructor. But when the user wants to load the greeting, they donโt care about
those implementation details, instead theyโre going to find and click the button
that says โLoad Greeting.โ And thatโs exactly what our test is doing with the
getByText
helper!
In addition, the wait
resembles exactly what the users does. They wait for the
greeting text to appear, however long that takes. In our tests weโre mocking
that out so it happens basically instantly, but our test doesnโt actually care
how long it takes. We donโt have to use a setTimeout
in our test or anything.
We simply say: โHey, wait until the greeting-text
node appears.โ (Note, in
this case itโs using a data-testid
attribute which is an escape hatch for
situations where it doesnโt make sense to find an element by any other
mechanism.
A data-testid
is definitely better then alternatives.
High-level Overviewย API
Originally, the library only provided queryByTestId
as a utility as suggested
in my blog post
โMaking your UI tests resilient to changeโ.
But thanks to feedback on that blog post from
Bergรฉ Greg as well as inspiration from
a fantastic (and short!) talk by
Jamie White, I added several more and now Iโm
even happier with this solution.
You can read more about the library and its APIs in
the official docs.
Hereโs a high-level overview of what this library gives you:
Simulate
:
a re-export from theSimulate
utility from
the
react-dom/test-utils
Simulate
object.wait
:
allows you to wait for a non-deterministic period of time in your tests.
Normally you should
mock out API requests
or
animations,
but even if youโre dealing with immediately resolved promises, youโll need
your tests to wait for the next tick of the event loop andwait
is really
good for that. (Big shout out to
ลukasz Gozda Gandecki who
introduced this
as a replacement for the (now deprecated)flushPromises
API).render
:
This is the meat of the library. Itโs fairly simple. It creates adiv
with
document.createElement
, then usesReactDOM.render
to render to thatdiv
.
The render
function returns the following objects and utilities:
container
:
Thediv
your component was rendered tounmount
:
A simple wrapper overReactDOM.unmountComponentAtNode
to unmount your
component (to facilitate easier testing ofcomponentWillUnmount
for
example).getByLabelText
:
Get a form control associated to a labelgetByPlaceholderText
:
Placeholders arenโt proper alternatives to labels, but if this makes more
sense for your use case itโs available.getByText
:
Get any element by its text content.getByAltText
:
Get an element (like an<img
) by itโsalt
attribute value.getByTestId
:
Get an element by itsdata-testid
attribute.
Each of those get*
utilities will throw a useful error message if no element
can be found. Thereโs also an associated query*
API for each which will return
null
instead of throwing an error which can be useful for asserting that an
element is not in the DOM.
Also, for these get*
utilities, to find a matching element, you can pass:
- a case-insensitive substring:
lo world
matchesHello World
- a regex:
/^Hello World$/
matchesHello World
- a function that accepts the text and the element:
(text, el) => el.tagName === 'SPAN' && text.startsWith('Hello')
would match
a span that has content that starts withHello
Custom Jestย Matchers
Thanks to Anto Aravinth Belgin Rayen, we have
some handy custom Jest matchers as well:
Note: now these have been extracted to
jest-dom which is maintained by
Ernesto Garcรญa
Conclusion
A big feature of this library is that it doesnโt have utilities that enable
testing implementation details. It focuses on providing utilities that encourage
good testing and software practices. I hope that by using
the
react-testing-library
your
React testbases are easier to understand and maintain.
Leave a Reply