Improving React Testing Library Tests

Updated on · 7 min read
Improving React Testing Library Tests

React Testing Library (RTL) became a de-facto standard when it comes to testing React components. Focus on testing from the user's point of view and avoidance of implementation details in the tests are some main reasons for its success.

Properly written tests can not only help to prevent regressions or buggy code but in the case of RTL also improve the accessibility of components and the user experience in general. In this post, we'll see how to get the most out of RTL tests. I'll provide a collection of what I consider to be good practices when testing, in no particular order of importance.

Writing smoke tests

Sometimes we want to have basic sanity tests to make sure that a component doesn't break when rendering. Let's say we have this simple component:

jsx
export const ListPage = () => { return ( <div className="text-list__container"> <h1>List of items</h1> <ItemList /> </div> ); };
jsx
export const ListPage = () => { return ( <div className="text-list__container"> <h1>List of items</h1> <ItemList /> </div> ); };

We could check that it renders without issues with a test like this:

jsx
import { render } from '@testing-library/react'; import React from 'react'; import { ListPage } from './ListPage'; describe('ListPage', () => { it('renders without breaking', () => { expect(() => render(<ListPage />)).not.toThrow(); }); });
jsx
import { render } from '@testing-library/react'; import React from 'react'; import { ListPage } from './ListPage'; describe('ListPage', () => { it('renders without breaking', () => { expect(() => render(<ListPage />)).not.toThrow(); }); });

This works fine for our purposes, however, we're underutilizing here the power of RTL. Instead, we could do this:

jsx
import { render, screen } from "@testing-library/react"; import React from "react"; import { ListPage } from "./ListPage"; describe("ListPage", () => { it("renders without breaking", () => { render(<ListPage />); expect( screen.getByRole("heading", { name: "List of items" }) ).toBeInTheDocument(); }); });
jsx
import { render, screen } from "@testing-library/react"; import React from "react"; import { ListPage } from "./ListPage"; describe("ListPage", () => { it("renders without breaking", () => { render(<ListPage />); expect( screen.getByRole("heading", { name: "List of items" }) ).toBeInTheDocument(); }); });

Although this is quite a simplified example, with this slight change, we're not only testing that the component doesn't break during render, but also that it has a header element with the name List of items, which is properly accessible by screen readers.

Default to *ByRole queries

One of the powerful advantages of RTL is that with the right queries we can ensure not only that components work as expected, but also that they are accessible. So how to figure out, which query is the best? The rule is quite simple - use *ByRole queries by default. Like most of the rules, it has exceptions since not all HTML elements have a default role. The list of default roles for HTML elements can be found at w3.org.

Let's consider the following form component, adapted from the previous tutorial:

jsx
export const Form = ({ saveData }) => { const [state, setState] = useState({ name: "", email: "", password: "", confirmPassword: "", conditionsAccepted: false, }); const onFieldChange = (event) => { let value = event.target.value; if (event.target.type === "checkbox") { value = event.target.checked; } setState({ ...state, [event.target.id]: value }); }; const onSubmit = (event) => { event.preventDefault(); saveData(state); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label>Name</label> <input id="name" onChange={onFieldChange} placeholder="Enter your name" /> </div> <div className="field"> <label>Email</label> <input type="email" id="email" onChange={onFieldChange} placeholder="Enter your email address" /> </div> <div className="field"> <label>Password</label> <input type="password" id="password" onChange={onFieldChange} placeholder="Password should be at least 8 characters" /> </div> <div className="field"> <label>Confirm password</label> <input type="password" id="confirmPassword" onChange={onFieldChange} placeholder="Enter the password once more" /> </div> <div className="field checkbox"> <input type="checkbox" id="conditions" onChange={onFieldChange} /> <label>I agree to the terms and conditions</label> </div> <button type="submit">Sign up</button> </form> ); };
jsx
export const Form = ({ saveData }) => { const [state, setState] = useState({ name: "", email: "", password: "", confirmPassword: "", conditionsAccepted: false, }); const onFieldChange = (event) => { let value = event.target.value; if (event.target.type === "checkbox") { value = event.target.checked; } setState({ ...state, [event.target.id]: value }); }; const onSubmit = (event) => { event.preventDefault(); saveData(state); }; return ( <form className="form" onSubmit={onSubmit}> <div className="field"> <label>Name</label> <input id="name" onChange={onFieldChange} placeholder="Enter your name" /> </div> <div className="field"> <label>Email</label> <input type="email" id="email" onChange={onFieldChange} placeholder="Enter your email address" /> </div> <div className="field"> <label>Password</label> <input type="password" id="password" onChange={onFieldChange} placeholder="Password should be at least 8 characters" /> </div> <div className="field"> <label>Confirm password</label> <input type="password" id="confirmPassword" onChange={onFieldChange} placeholder="Enter the password once more" /> </div> <div className="field checkbox"> <input type="checkbox" id="conditions" onChange={onFieldChange} /> <label>I agree to the terms and conditions</label> </div> <button type="submit">Sign up</button> </form> ); };

The way we'd test it is by simulating entering the data via form elements, submitting the form, and then verifying that the saveData prop received the data we typed in. We can break it down into 3 steps:

  1. Enter the text for fields we want to test (or click on the checkbox)
  2. Click the Sign up button
  3. Verify that saveData was called with the data we entered.

This workflow is exactly how a user would interact with our form (except that maybe they wouldn't inspect the saved data in quite the same way). Let's start with entering the name into the first input field. We see that it has an Enter your name placeholder, so why not use that?

jsx
const defaultData = { conditionsAccepted: false, confirmPassword: "", email: "", name: "", password: "", }; describe("Form", () => { it("should save correct data on submit", () => { const mockSave = jest.fn(); render(<Form saveData={mockSave} />); fireEvent.change(screen.getByPlaceholderText("Enter your name"), { target: { value: "Test" }, }); fireEvent.submit(screen.getByText("Sign up")); expect(mockSave).toHaveBeenLastCalledWith({ ...defaultData, name: "Test" }); }); });
jsx
const defaultData = { conditionsAccepted: false, confirmPassword: "", email: "", name: "", password: "", }; describe("Form", () => { it("should save correct data on submit", () => { const mockSave = jest.fn(); render(<Form saveData={mockSave} />); fireEvent.change(screen.getByPlaceholderText("Enter your name"), { target: { value: "Test" }, }); fireEvent.submit(screen.getByText("Sign up")); expect(mockSave).toHaveBeenLastCalledWith({ ...defaultData, name: "Test" }); }); });

It works, but we can do better than that. Firstly, this may foster the practice of using placeholder text as labels, which is not what they're meant for and is discouraged by W3C WAI. Secondly, we're not testing with accessibility concerns in mind. Instead, let's try replacing our query with getByRole. As the documentation says, we can match the input of type text by the textbox role, however since we have multiple textboxes in the form, we need to be more specific than that. Luckily, the query accepts a second param, which is an options object, where we can narrow down the match using the name attribute. From the documentation we can see that name here doesn't mean the input's name attribute, but its accessible name. Consequently, for inputs the accessible name, is often the text content of their labels. In our form, the name input has a Name label, so let's use that.

jsx
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Test" }, });
jsx
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Test" }, });

When running the test we get an error:

shell
TestingLibraryElementError: Unable to find an accessible element with the role "textbox" and name "Name"

and the help text below shows that our input doesn't have an accessible name:

shell
Here are the accessible roles: textbox: Name "": <input id="name" placeholder="Enter your name" />

We do have a label for the input, so why is this not working? It turns out that the label needs to be associated with the input. To achieve this, the label should have a for attribute matching the associated input's id. Looks like our input already has an id, so we just need to add the for (htmlFor when using React) attribute to it:

jsx
<label htmlFor="name">Name</label> <input id="name" onChange={onFieldChange} placeholder="Enter your name" />
jsx
<label htmlFor="name">Name</label> <input id="name" onChange={onFieldChange} placeholder="Enter your name" />

Now the input is properly associated with its label and the test passes. This also brings a major improvement to accessibility. Firstly, when clicking/tapping a label, the focus will be passed to the associated input. Secondly, and most importantly, screen readers will read out the label when input is focused, thus providing some extra information about input to the user. This is a good example of how by switching to getByRole we not only have improved the test coverage but also provided a valuable accessibility improvement to our form component.

If we look at the test again, we see that the getByText query is used for the submit button. In my opinion, *ByText should be the last resort (or maybe second-to-last before *ByTestId) as they are the most prone to breaking. In our test, screen.getByText("Sign up") will match the element with a text node that has a Sign up text content. If we decide later on to add a paragraph on the same page with the text "Sign up", that element will be also matched and the test will break, since now we have more than one matching element. It becomes worse when we use a generic regex for the text match instead of the string - screen.getByText(/Sign up/i). This will match any occurrence of the string "sign up" regardless of the case, even if it is a part of a larger sentence. Of course, we could modify the regex to ensure it matches only this specific string, but instead, we can use a more precise query and at the same time verify that our form is accessible with the help of the getByRole query. In this case, the query will be screen.getByRole("button", { name: "Sign up" });, the accessible name this time is the actual text content of the button. Note that if we add aria-label to the button, the accessible name instead will be the text content of that aria-label. Ultimately, the updated test looks like this:

jsx
describe("Form", () => { it("should save correct data on submit", () => { const mockSave = jest.fn(); render(<Form saveData={mockSave} />); fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Test" }, }); fireEvent.submit(screen.getByRole("button", { name: "Sign up" })); expect(mockSave).toHaveBeenLastCalledWith({ ...defaultData, name: "Test" }); }); });
jsx
describe("Form", () => { it("should save correct data on submit", () => { const mockSave = jest.fn(); render(<Form saveData={mockSave} />); fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Test" }, }); fireEvent.submit(screen.getByRole("button", { name: "Sign up" })); expect(mockSave).toHaveBeenLastCalledWith({ ...defaultData, name: "Test" }); }); });

*ByRole vs *ByLabelText for input elements

The purpose of using *ByRole queries for input elements is to match input by its associated label. Wouldn't it be easier to use *ByLabelText queries instead, as they ultimately achieve the same goal and the syntax is a bit lighter? I don't think there is that big of a difference in using one query vs another, however, *ByRole is more robust when matching elements and will still work if you switch from <label> to aria-label. On the other hand, not all types of input elements have a default role, so for example for the password input, we'd use the *ByLabelText query.

Use userEvent instead of fireEvent

In the tests for the Form component, we use the built-in fireEvent to dispatch DOM events. While this works in a lot of cases, fireEvent is just a light wrapper on top of dispatchEvent API and does not simulate full user interaction. userEvent, on the other hand, manipulates the DOM in the same way as a user would in a browser, providing more reliable testing experience. The way it works also aligns better with the philosophy of RTL, plus the syntax is clearer. Compare the test for Form, rewritten with userEvent:

jsx
describe("Form", () => { it("should save correct data on submit", async () => { const mockSave = jest.fn(); render(<Form saveData={mockSave} />); await userEvent.type(screen.getByRole("textbox", { name: "Name" }), "Test"); await userEvent.click(screen.getByRole("button", { name: "Sign up" })); expect(mockSave).toHaveBeenLastCalledWith({ ...defaultData, name: "Test" }); }); });
jsx
describe("Form", () => { it("should save correct data on submit", async () => { const mockSave = jest.fn(); render(<Form saveData={mockSave} />); await userEvent.type(screen.getByRole("textbox", { name: "Name" }), "Test"); await userEvent.click(screen.getByRole("button", { name: "Sign up" })); expect(mockSave).toHaveBeenLastCalledWith({ ...defaultData, name: "Test" }); }); });

All the methods are async, so we need to slightly adjust the test. Additionally, as it's a separate package it needs to be installed via npm i -D @testing-library/user-event. Note that in the future version you'll be required to set up the events via const user = userEvent.setup() and call them from user. A more in-depth introduction to userEvent is available in RTL docs.

Use find* queries instead of waitFor

Quite often there are cases when the element we're trying to match is not available on the initial render, e.g. when we first fetch the items from API and then display them. In such cases, we need the component to finish all its rendering cycles before querying. As an example, let's modify the ListPage component to wait for the list of items to load asynchronously:

jsx
export const ListPage = () => { const [items, setItems] = useState([]); useEffect(() => { const loadItems = async () => { setTimeout(() => setItems(["Item 1", "Item 2"]), 100); }; loadItems(); }, []); if (!items.length) { return <div>Loading...</div>; } return ( <div className="text-list__container"> <h1>List of items</h1> <ItemList items={items} /> </div> ); };
jsx
export const ListPage = () => { const [items, setItems] = useState([]); useEffect(() => { const loadItems = async () => { setTimeout(() => setItems(["Item 1", "Item 2"]), 100); }; loadItems(); }, []); if (!items.length) { return <div>Loading...</div>; } return ( <div className="text-list__container"> <h1>List of items</h1> <ItemList items={items} /> </div> ); };

The current version of the test for this component will no longer work since when the screen.getByRole query is called, only the loading text is displayed. To wait for the component to complete loading we can use the waitFor helper:

jsx
import { waitFor } from "@testing-library/react"; //... describe("ListPage", () => { it("renders without breaking", async () => { render(<ListPage />); await waitFor(() => { expect( screen.getByRole("heading", { name: "List of items" }) ).toBeInTheDocument(); }); }); });
jsx
import { waitFor } from "@testing-library/react"; //... describe("ListPage", () => { it("renders without breaking", async () => { render(<ListPage />); await waitFor(() => { expect( screen.getByRole("heading", { name: "List of items" }) ).toBeInTheDocument(); }); }); });

This works, but there is a query type with async behavior built-in and its findBy* queries, which is a wrapper on top of waitFor. With it the test becomes a bit more readable:

jsx
describe("ListPage", () => { it("renders without breaking", async () => { render(<ListPage />); expect( await screen.findByRole("heading", { name: "List of items" }) ).toBeInTheDocument(); }); });
jsx
describe("ListPage", () => { it("renders without breaking", async () => { render(<ListPage />); expect( await screen.findByRole("heading", { name: "List of items" }) ).toBeInTheDocument(); }); });

It should be noted that one await call per test block is usually enough, as at that time all the async actions have been resolved. So in the example above, if we want to additionally test that we have 4 items in the ItemList, we don't need to use async findBy* but can resort to getBy* instead.

Testing element's disappearance

This is quite an edge case, but sometimes we want to test that an element, which was present before, has been removed from the DOM after some async action. RTL has a handy helper for that - waitForElementToBeRemoved. For example, in the ListItem component we might want to wait for the Loading... text to be removed instead of the list header to appear:

jsx
it("renders without breaking", async () => { render(<ListPage />); await waitForElementToBeRemoved(() => screen.queryByText("Loading...")); });
jsx
it("renders without breaking", async () => { render(<ListPage />); await waitForElementToBeRemoved(() => screen.queryByText("Loading...")); });

Use RTL playground

If you have trouble figuring out the right query for certain elements, RTL playground is of great help there. Simply paste the HTML for the component being tested and it will provide handy suggestions about which queries would work for each element. It's quite valuable, particularly for complex components where it might not always be evident, which query is best to use.

P.S.: Are you looking for a reliable and user-friendly hosting solution for your website or blog? Cloudways is a managed cloud platform that offers hassle-free and high-performance hosting experience. With 24/7 support and a range of features, Cloudways is a great choice for any hosting needs.