Let’s talk about best practices in React Testing Library
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
⚠️ 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 returnnull
for no matches. Your assertion would be:
expect(screen.queryByText('Hello World')).toBeNull()
- Querying by Role
UsebyRole
when querying an interactive element that is exposed in the accessibility tree. Please check all the available roles.
screen.getByRole(role, options?)
screen.getAllByRole(role, option?)
screen.queryByRole(role, options?)
screen.findAllByRole(role, options?)
- Querying by Label Text
UsebyLabelText
when testing form fields.
screen.getByLabelText(text, options?)
screen.getAllByLabelText(text, option?)
screen.queryByLabelText(text, options?)
screen.findAllByLabelText(text, options?)
- Querying by Placeholder Text
UsebyPlaceholderText
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
UsebyText
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
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.