Improving React Testing Library Tests
Updated on · 7 min read|
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:
jsxexport const ListPage = () => { return ( <div className="text-list__container"> <h1>List of items</h1> <ItemList /> </div> ); };
jsxexport 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:
jsximport { render } from '@testing-library/react'; import React from 'react'; import { ListPage } from './ListPage'; describe('ListPage', () => { it('renders without breaking', () => { expect(() => render(<ListPage />)).not.toThrow(); }); });
jsximport { 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:
jsximport { 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(); }); });
jsximport { 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:
jsxexport 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> ); };
jsxexport 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:
- Enter the text for fields we want to test (or click on the checkbox)
- Click the
Sign up
button - 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?
jsxconst 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" }); }); });
jsxconst 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.
jsxfireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Test" }, });
jsxfireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Test" }, });
When running the test we get an error:
shellTestingLibraryElementError: 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:
shellHere 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:
jsxdescribe("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" }); }); });
jsxdescribe("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
:
jsxdescribe("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" }); }); });
jsxdescribe("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:
jsxexport 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> ); };
jsxexport 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:
jsximport { 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(); }); }); });
jsximport { 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:
jsxdescribe("ListPage", () => { it("renders without breaking", async () => { render(<ListPage />); expect( await screen.findByRole("heading", { name: "List of items" }) ).toBeInTheDocument(); }); });
jsxdescribe("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:
jsxit("renders without breaking", async () => { render(<ListPage />); await waitForElementToBeRemoved(() => screen.queryByText("Loading...")); });
jsxit("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.
