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 as fireEvent(node: HTMLElement, event: Event)) fires a single event (like click)
  • userEvent fires a realistic simulation of all events (like mouse over -> click, or focus -> key down -> key up -> input change). You can also use userEvent to set up more complex interactions which would have been quite difficult if using fireEvent() 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.

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 Query0 Matches1 Match>1 MatchesRetry (Async/Await)
Single Element
getBy...Throw errorReturn elementThrow errorNo
queryBy...Return nullReturn elementThrow errorNo
findBy...Throw errorReturn elementThrow errorYes
Multiple Elements
getAllBy...Throw errorReturn arrayReturn arrayNo
queryAllBy...Return []Return arrayReturn arrayNo
findAllBy...Throw errorReturn arrayReturn arrayYes

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.

© 2019-2023 a5h.dev.
All Rights Reserved. Use information found on my site at your own risk.