Let’s talk about best practices in React Testing Library

Tay Bencardino
5 min readMar 15, 2024

--

Photo by Darren Richardson on Unsplash

Writing unit tests using React Testing Library (RTL) creates maintainable tests that give us high confidence that the components we build work for the users. With RTL, we can refactor our components, changing the implementation (but not the functionality) without breaking any tests and avoiding slowing us down.

User perspective

It’s important to keep in mind that RTL is about having a user-centric approach. Tests are written to simulate user interactions and behaviours with the application rather than focusing on the internal implementation details of the components. The goal is to ensure that the components behave as expected from a user’s perspective, leading to tests that are more resilient to changes in the implementation.

Let’s give an example with an H1 tag saying “Hello world”. We know it’s there, and we can use .toBeInTheDocument() to make sure it is there. However, the user wants to visualise this element too. .toBeInTheDocument() could give us a false positive because the text could be hidden or transparent. Which means the user does not see anything. In that case, we should use .toBeVisible().

render(<MyComponent />)
const title = screen.getByRole('heading')

expect(title).toHaveTextContent('Hello world')
expect(title).toBeVisible()

Use of meaningful assertions

It is important to use assertions that the jest-dom library offers to make our tests readable. For instance, if we want to test a button, it would be more useful to test if it is clickable than to check if the DOM has rendered the button.

expect(submitButton).toBeEnabled()
expect(submitButton).toBeDisabled()

Another case is testing whether a function has been called. Make sure it has been called with the data you are expecting and for the correct number of times.

// don't use
expect(functionMock).toHaveBeenCalled()

// do use
expect(functionMock).toHaveBeenCalledWith([1,2,3])
expect(functionMock).toHaveBeenCalledTimes(1)

Use of screen

It has been available for use in @testing-library/react version 9. The use of screen makes our tests easy to maintain because we don’t have to update the render call when adding or deleting queries.

In React Testing Library, the prefixes getBy, findBy, queryBy, getAllBy, and findAllBy are used to query elements in the DOM. They are combined with different methods to perform various types of queries.

Here, you can find the difference between those queries and when to use them: https://testing-library.com/docs/react-testing-library/cheatsheet/#queries

Table for type of queries

⚠️ Do not use getBy to ensure that an element does not exist in the document.

expect(screen.getByText('Hello World')).not.toBeInTheDocument()

This will fail with an error, so when you are asserting that something won’t be in the document, you should use queryBy instead as that will return null for no matches. Your assertion would be:

expect(screen.queryByText('Hello World')).toBeNull()

screen.getByRole(role, options?)
screen.getAllByRole(role, option?)
screen.queryByRole(role, options?)
screen.findAllByRole(role, options?)
  • Querying by Label Text
    Use byLabelText when testing form fields.
screen.getByLabelText(text, options?)
screen.getAllByLabelText(text, option?)
screen.queryByLabelText(text, options?)
screen.findAllByLabelText(text, options?)
  • Querying by Placeholder Text
    Use byPlaceholderText when there are no labels in your form fields.
screen.getByPlaceholderText(text, options?)
screen.getAllByPlaceholderText(text, option?)
screen.queryByPlaceholderText(text, options?)
screen.findAllByPlaceholderText(text, options?)
  • Querying By Text
    Use byText when testing a non-interact element, like divs, spans, and paragraphs.
screen.getByText(text, options?)
screen.getAllByText(text, option?)
screen.queryByText(text, options?)
screen.findAllByText(text, options?)

⚠️ These methods cover various scenarios for querying elements based on different criteria. You can also pass optional options as the second argument to these methods for more specific queries or to control the behaviour of the query.

E.g.: name, value, title, placeholder, hidden, etc.
const submitButton = screen.getByRole('button', { name: 'Submit' });
Please check here the most common options you can use.

Additionally, these methods are designed to work well with asynchronous code and are often used with async/await when dealing with promises returned from functions.

Priority of queries

Table for priority of queries

Use of user-event

@testing-library/user-event is a package that's built on top of fireEvent. It provides a higher level of abstraction and simulates user interactions more closely.

user-event triggers not only DOM events but also browser events, resulting in a test environment that more accurately mirrors how users interact with the application.

// don't use
fireEvent.change(input, {target: {value: 'hello'}})

// do use
await userEvent.type(input, 'hello')

fireEvent.change will trigger a single change event on the input. However the type call, will trigger keyDown, keyPress, and keyUp events for each character as well. It's much closer to the user's actual interactions. This has the benefit of working well with libraries that you may use which don't actually listen for the change event.

Other common examples of using userEvent instead of fireEvent:

  • Clicking a button
// don't use
fireEvent.click(button)
// do use
await userEvent.click(button)
  • Clearing an input
// don't use
fireEvent.change(input, {target: {value: ''}})
// do use
await userEvent.clear(input)
  • Selecting an option in a dropdown
// don't use
fireEvent.change(selectElement, {target: {value: 'optionValue'}})
// do use
await userEvent.selectOptions(selectElement, 'optionValue')
  • Checking a checkbox
// don't use
fireEvent.click(checkbox)
// do use
await userEvent.click(checkbox)
  • Focusing on an element
// don't use
fireEvent.focus(input)
// do use
await userEvent.tab()
  • Blurring an element
// don't use
fireEvent.blur(input)
// do use
await userEvent.tab()
  • Hovering over an element
// don't use
fireEvent.mouseOver(element)
// do use
await userEvent.hover(element)

Embracing best practices in React Testing Library (RTL) elevates the reliability of unit tests, fostering maintainable codebases with high user confidence. RTL’s user-centric approach prioritizes tests that simulate authentic user interactions, ensuring components behave as expected from a user perspective while remaining resilient to implementation changes.

Leveraging meaningful assertions from the jest-dom library enhances test readability and accuracy, while the screen object streamlines test maintenance. Utilising RTL's querying methods and optional options enables precise element targeting, promoting flexible and robust tests.

Additionally, integrating @testing-library/user-event for realistic user interactions, make the tests closely aligned with user behaviour. Developers cultivate a testing environment conducive to stability, functionality, and user satisfaction through these practices.

--

--