Testing with React Testing Library - tips and tricks
Last updated on June 2022
Here are some notes/tips/tricks when it comes to testing in React.
Some are quite basic things, some are things I see less frequently in codebases.
Most (if not all) of these tips/tricks relate to React Testing Library (RTL) + Jest
Know about fireEvent vs userEvent
fireEvent()
(defined asfireEvent(node: HTMLElement, event: Event)
) fires a single event (likeclick
)userEvent
fires a realistic simulation of all events (likemouse over -> click
, orfocus -> key down -> key up -> input change
). You can also useuserEvent
to set up more complex interactions which would have been quite difficult if usingfireEvent()
by itself.
You should always try to use userEvent
if possible as it is more realistic and might catch bugs that you would miss if just firing a single event with fireEvent()
. Here is a nice write up.
When using userEvent
, you should first call setup()
to set up the fake 'user' environment. (It is optional to call setup()
, but this is only for backwards compatibility. Going forward it should be called).
import userEvent from '@testing-library/user-event'
const user = userEvent.setup()
await user.keyboard('[ShiftLeft>]') // Press Shift (without releasing it)
await user.click(element) // Perform a click with `shiftKey: true`
See this page for a list of options you can configure it with.
If you are dealing with complex interactions (such as simulating someone pressing a key (like shift) and holding it down while pressing other keys), then check out their docs as its quite easy to write realistic tests.
getByText() (and similar functions that use a TextMatch argument) with case-insensitive/substring search
You can pass in an options param when calling the functions that take a TextMatch
as the first argument. You can make it not exact, to search for substrings or case-insensitive strings:
screen.getByText('Hello World') // full string match
screen.getByText('llo Worl', {exact: false}) // substring match
screen.getByText('hello world', {exact: false}) // ignore case
You can also pass in a regex:
screen.getByText(/World/) // substring match
screen.getByText(/world/i) // substring match, ignore case
screen.getByText(/^hello world$/i) // full string match, ignore case
screen.getByText(/Hello W?oRlD/i) // substring match, ignore case, searches for "hello world" or "hello orld"
Or pass in a custom function, which will be run on every element:
screen.getByText((content, element) => content.startsWith('Hello'))
When passing in the 2nd argument ({exact: false}
in the above example), you have a few more options.
The first one is exact
- When
true
(default behaviour), it matches the exact string (case-sensitive) - when
false
(like{ exact: false }
), it will match substrings and is case-insensitive) - its value has no effect on regex/function args
- It also does not work on the
*byRole
queries.
The second option is normalizer
({ normalizer: yourFn }
) to override normalization.
The default normalization works by trimming whitespace (at start/end), and converts multiple adjacent whitespace characters into one space character.
So <div> hello world </div>
would be normalized to <div>hello world</div>
.
If you want to pass in your own normalizer
override you have two main approaches:
The first is to use the default normalizer, but pass in some arguments to set the whitespace options. You can do something like:
screen.getByText('text', {
normalizer: getDefaultNormalizer({trim: false, collapseWhitespace: false}),
})
Or you can implement it from scratch yourself, which takes in a string argument, and returns a string
screen.getByText('text', {
// custom normalizer, to locale uppercase + trim
normalizer: (text: string) => text.toLocaleUpperCase().trim()
})
Learn the 'options' param for querying
I'll paste in the function signature for some of the common querying functions, you can see there are quite a few options that can be very useful sometimes.
Note: TextMatch
is described above (either string, regex, or a function). This section is focusing on the options
parameters.
getByText(
text: TextMatch,
options?: {
selector?: string = '*',
exact?: boolean = true,
ignore?: string|boolean = 'script, style',
normalizer?: NormalizerFn,
}): HTMLElement
The more useful one for getByText
is the selector
. If you have multiple elements with the same text, you can sometimes easily target what you need by using it like { selector: 'button' }
The getByRole
takes in a string role (like 'button', 'img' etc). It is useful when used with form elements, as you can query for things like is it checked { checked: true }
.
getByRole(
role: string | TextMatch,
options?: {
hidden?: boolean = false,
name?: TextMatch,
description?: TextMatch,
selected?: boolean,
checked?: boolean,
pressed?: boolean,
suggest?: boolean,
current?: boolean | string,
expanded?: boolean,
queryFallbacks?: boolean,
level?: number,
}): HTMLElement
Note: byRole
should normally be used over things like byText
etc. e.g. screen.getByRole('button', {name: /send message/i})
getByLabelText(
text: TextMatch,
options?: {
selector?: string = '*',
exact?: boolean = true,
normalizer?: NormalizerFn,
}): HTMLElement
Use the built-in debug help functions
I've seen lots of use of console.log()
, or expect(screen).toMatchInlineSnapshot()
.
You can (and should) use prettyDOM()
when doing this.
Its a bit easier to use screen.debug()
, which will output
import {screen} from '@testing-library/dom'
document.body.innerHTML = `
<button>test</button>
<span>multi-test</span>
<div>multi-test</div>
`
// debug document
screen.debug()
// debug single element
screen.debug(screen.getByText('test'))
// debug multiple elements
screen.debug(screen.getAllByText('multi-test'))
But sometimes if you're working with a large component the HTML can be huge to navigate.
(In some cases it can be nicer to use .toMatchInlineSnapshot()
temporarily to see the DOM in your editor instead of the terminal).
You should definitely check out screen.logTestingPlaygroundURL()
. This will log the output to https://testing-playground.com/, and return a URL you can click and explore easily in your browser.
Example:
import {screen} from '@testing-library/dom'
document.body.innerHTML = `
<button>test</button>
<span>multi-test</span>
<div>multi-test</div>
`
// log entire document to testing-playground
screen.logTestingPlaygroundURL()
// log a single element
screen.logTestingPlaygroundURL(screen.getByText('test'))
Make use of the testing playground Chrome extension
If you are unsure of the best way to select an element in RTL, I'd recommend using the Testing Playground chrome plugin. It lets you select an element in your browser, and suggest the best way to select that item using the RTL query functions (getByText, getByLabel etc)
Increase debug log lines
If you are ever trying to debug an issue and you are getting frustrated that there is so much HTML that you cannot see it (it gets truncated at 7000 characters by default), then you can just set the DEBUG_PRINT_LIMIT
environment variable.
e.g.
DEBUG_PRINT_LIMIT=100000 npm run test
# or if you use yarn: DEBUG_PRINT_LIMIT=100000 yarn test
(On windows? This won't work... Use cross-env
in your package.json scripts to pass in the env var)
Learn the difference between getBy, queryBy, findBy
This is a really obvious one, but you have to know the difference between these three (and getAllBy
, queryAllBy
, findAllBy
).
Type of Query | 0 Matches | 1 Match | >1 Matches | Retry (Async/Await) |
---|---|---|---|---|
Single Element | ||||
getBy... | Throw error | Return element | Throw error | No |
queryBy... | Return null | Return element | Throw error | No |
findBy... | Throw error | Return element | Throw error | Yes |
Multiple Elements | ||||
getAllBy... | Throw error | Return array | Return array | No |
queryAllBy... | Return [] | Return array | Return array | No |
findAllBy... | Throw error | Return array | Return array | Yes |
Query 'within' elements
There is a within()
helper you can use to query within that element's child DOM tree.
import {render, within} from '@testing-library/react'
const {getByText} = render(<MyComponent />)
const messages = getByText('messages')
const helloMessage = within(messages).getByText('hello')
Query for elements to be removed from the DOM
There is a waitForElementToBeRemoved()
helper which you can call to check that an element to be removed. It is a wrapper around the waitFor
The first time it is called, the callback must not return null. It will keep retrying until the callback returns null.
await waitForElementToBeRemoved(() =>
screen.getByLabelText(/loading/i),
)
Don't forget about these accessibility testing functions
I really like React Testing Library as it encourages querying in a way that encourages correct semantic markup (which helps a lot for accessibility).
There are some useful helper functions built in that are there just to help with a11y testing.
These functions are not replacements for proper a11y testing
getRoles()
The getRoles()
function gets all roles in the DOM.
import {getRoles} from '@testing-library/dom'
const nav = document.createElement('nav')
nav.innerHTML = `
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>`
console.log(getRoles(nav))
// Object {
// navigation: [<nav />],
// list: [<ul />],
// listitem: [<li />, <li />]
// }
You can also use the logRoles(nav)
helper function while debugging.
isInaccessible()
Pass in an element to isInaccessible(yourEl)
and it will return a boolean to tell you if it passes some basic a11y tests.
This function will compute if the given element should be excluded from the accessibility API by the browser. It implements every MUST criteria from the Excluding Elements from the Accessibility Tree section in WAI-ARIA 1.2 with the exception of checking the role attribute.
This checks for things like CSS is not display: none
or visibility: hidden
, and that there is not a hidden
attribute on the element. It also checks aria-hidden
, and that its parent element(s) are not preventing it from being displayed (like if you had a heading inside a <img>
tag)
Mock components
I am a fan of integration tests in FE (rather than unit testing a component and mocking everything out). But sometimes its awkward, e.g. with components that have complex transitions. If they're not the component you are testing, it can be nicer to just mock them out.
Here is an example:
// from react testing library faq
jest.mock('react-transition-group', () => {
const FakeTransition = jest.fn(({children}) => children)
const FakeCSSTransition = jest.fn(props =>
props.in ? <FakeTransition>{props.children}</FakeTransition> : null,
)
return {CSSTransition: FakeCSSTransition, Transition: FakeTransition}
})
test('you can mock things with jest.mock', () => {
const {getByTestId, queryByTestId} = render(
<HiddenMessage initialShow={true} />,
)
expect(queryByTestId('hidden-message')).toBeTruthy() // we just care it exists
// hide the message
fireEvent.click(getByTestId('toggle-message'))
// in the real world, the CSSTransition component would take some time
// before finishing the animation which would actually hide the message.
// So we've mocked it out for our tests to make it happen instantly
expect(queryByTestId('hidden-message')).toBeNull() // we just care it doesn't exist
})
Test prop changes with 'rerender()'
If you ever want to test prop changes directly (and not via testing a parent component that changes the props), you can use rerender()
and pass in the new props.
import {render} from '@testing-library/react'
const {rerender} = render(<NumberDisplay number={1} />)
// re-render the same component with different props
rerender(<NumberDisplay number={2} />)
Test unmount
I think for most components its safe to assume we don't need to test unmount.
But if we have hooks that return a function to be run on unmount (e.g. removing event listeners), it can be worth testing it to be sure it works as expected.
import {render} from '@testing-library/react'
const {container, unmount} = render(<YourPage />)
unmount()
// make assertions here
Use Testing Library for more than just React
If you are familiar with React Testing Library, and find yourself using something other than React then you might want to use Testing Library there too.
Here is a list of some other supported frameworks in the * Testing Library
family:
- React Testing Library
- React Native Testing Library
- Vue Testing Library
- Angular Testing Library
- Preact Testing Library
- Svelte Testing Library
- Cypress Testing Library
Other libraries to be aware of
RTL eslint https://github.com/testing-library/eslint-plugin-testing-library
I'd recommend enabling this if you use eslint as it encourages best practices and helps avoid common mistakes when writing tests with Testing Library.
rtl simple queries https://github.com/balavishnuvj/rtl-simple-queries
I am not personally a fan, as it is quite easy to learn the standard querying methods. But using the 'RTL simple queries' library does simplify that initial learning curve.
testing library selectors https://github.com/domasx2/testing-library-selector
This library gives you a way to reuse query selectors. This can be useful if you often have to build up selectors for certain elements (like a button)
import { byLabelText, byRole, byTestId } from './selector';
// define reusable selectors
const ui = {
container: byTestId('my-container'),
submitButton: byRole('button', { name: 'Submit' }),
usernameInput: byLabelText('Username:'),
// can encode more specific html element type
passwordInput: byLabelText<HTMLInputElement>('Password:'),
};
// reuse them in the same test or across multiple tests by calling
// .get(), .getAll(), .find(), .findAll(), .query(), .queryAll()
it('example test', async () => {
// by default elements will be queried against screen
await ui.submitButton.find();
expect(ui.submitButton.query()).not.toBeInDocument();
expect(ui.submitButton.get()).toBeInDocument();
const containers = ui.container.getAll();
expect(containers).toHaveLength(3);
// provide a container as first param to query element inside that container
const username = ui.usernameInput.get(containers[0]);
});
Speed
Using the getByRole
(and findByRole
/queryByRole
) is often significantly slower than something like getByText
. This is because it must build up a tree of all roles in the mounted component.
If you are testing large components (with lots of DOM elements) and you notice slow tests, you may want to consider using faster query selectors.