Testing React Hook Form With React Testing Library
Updated on · 8 min read|React Hook Form has emerged as a popular and efficient library for managing form state and validation in React applications. It simplifies handling form inputs, reduces boilerplate code, and provides a performant solution for form management. However, testing these forms efficiently and accurately is just as important as implementing them, whether it is a simple contact form, multistep form or even a complex dynamic form.
In this post, we'll explore how to use React Testing Library to test components that use React Hook Form. We'll cover the basics of setting up a testing environment, writing test cases for various scenarios, and some of the best practices for ensuring your forms are thoroughly tested and reliable.
We'll be writing tests for the RecipeForm
component, a basic recipe form from an earlier post.
Setting up
In addition to React Testing Library, we'll be using Jest - a popular test runner. Since we used Create React App to scaffold the recipe form, Jest comes pre-installed and is ready to be used with it. However, we still need to install React Testing Library, user-event for simulating user interactions, and jest-dom to use custom Jest matchers.
bashnpm i -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
bashnpm i -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
Testing that the component is rendered correctly
Now we can start writing tests for the RecipeForm
component. Let's create a RecipeForm.test.ts file and add the first test checking that basic fields are properly rendered.
tsximport { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import { RecipeForm } from "./RecipeForm"; it("should render the basic fields", () => { render(<RecipeForm />); expect( screen.getByRole("heading", { name: "New recipe" }), ).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: /name/i })).toBeInTheDocument(); expect( screen.getByRole("textbox", { name: /description/i }), ).toBeInTheDocument(); expect( screen.getByRole("spinbutton", { name: /servings/i }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /add ingredient/i }), ).toBeInTheDocument(); expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); });
tsximport { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import { RecipeForm } from "./RecipeForm"; it("should render the basic fields", () => { render(<RecipeForm />); expect( screen.getByRole("heading", { name: "New recipe" }), ).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: /name/i })).toBeInTheDocument(); expect( screen.getByRole("textbox", { name: /description/i }), ).toBeInTheDocument(); expect( screen.getByRole("spinbutton", { name: /servings/i }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /add ingredient/i }), ).toBeInTheDocument(); expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); });
For those familiar with React Testing Library, you might notice that we're not using the getByText
query here, but instead defaulting to getByRole
. This approach is preferred because it more closely resembles how users interact with the page, both through visual display and assistive technologies. This is one particularly compelling reason to use React Testing Library — if your code is written with accessibility concerns in mind, the getByRole
query will be sufficient in most cases.
To effectively use *ByRole
queries, it's necessary to understand the ARIA role of each HTML element. In our form, we use h1
with a heading role, text input
and textarea
with textbox roles, a number input
with a spinbutton role, and a button
with a button role. Since we have multiple elements with the same role, we can use the name
option to narrow down the search and match specific elements. Note that this is not the name attribute we give to input elements, but their accessible name, which is used by assistive technologies to identify HTML elements.
There are several rules that browsers use to compute accessible names. For our purposes, an input's accessible name is computed from its associated elements, in this case, its label. However, for this association to be effective, the label must be properly connected with the input; for example, the input can be wrapped in the label, or the label can have a for
attribute that corresponds to the input's id
. This demonstrates how creating accessible forms simplifies their testing.
For the button, provided there are no aria-label
or associated aria-labelledby
attributes (which take precedence over other custom-provided and native accessible names), the accessible name is computed using the button's content. In this case, it's the Add ingredient and Save texts.
We use regex syntax to match the name, which is convenient, for example, for case-insensitive matches. However, you need to be cautious, as a basic regex /text/i
will match all the elements containing text
, which is not what we want. In such cases, specifying the word boundaries should help - /^text$/i
.
If you're considering migrating your tests from Enzyme to React Testing Library or are simply curious about their differences, I've written a separate post addressing the topic: Enzyme vs React Testing Library: A Migration Guide.
Testing field validation
Now that we have the basic tests done, let's move on to testing field validation. Before doing so, we will make a minor adjustment to the form component by introducing a saveData
prop. This function will be invoked upon form submission, enabling us to verify its execution and examine the arguments passed.
tsxinterface Props { saveData: (data: Recipe) => void; } export const RecipeForm = ({ saveData }: Props) => { const { register, handleSubmit, formState: { errors }, control, } = useForm<Recipe>(); const { fields, append, remove } = useFieldArray({ name: "ingredients", control, }); const submitForm = (formData: Recipe) => { saveData(formData); }; //... };
tsxinterface Props { saveData: (data: Recipe) => void; } export const RecipeForm = ({ saveData }: Props) => { const { register, handleSubmit, formState: { errors }, control, } = useForm<Recipe>(); const { fields, append, remove } = useFieldArray({ name: "ingredients", control, }); const submitForm = (formData: Recipe) => { saveData(formData); }; //... };
Normally, saveData
would make an API call to send the form data to the server or perform some data processing. However, for field validation, we're only interested in whether this function is called or not. If any of the fields are invalid, the form's onSubmit
callback is not invoked.
Since we're using userEvent
to simulate user actions, we need to set it up first.
jsimport { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import userEvent from "@testing-library/user-event"; import React from "react"; import { RecipeForm } from "./RecipeForm"; // setup userEvent function setup(jsx: React.JSX.Element) { return { user: userEvent.setup(), ...render(jsx), }; }
jsimport { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import userEvent from "@testing-library/user-event"; import React from "react"; import { RecipeForm } from "./RecipeForm"; // setup userEvent function setup(jsx: React.JSX.Element) { return { user: userEvent.setup(), ...render(jsx), }; }
Here, we introduce a small utility function that sets up the userEvent
and renders the tested component. This function returns the user
object which is used for simulating interactions. Keep in mind that all its methods are asynchronous, so they must be called using await
.
Now we can test that the form validation works as expected.
tsxit("should validate form fields", async () => { const mockSave = jest.fn(); const { user } = setup(<RecipeForm saveData={mockSave} />); await user.type( screen.getByRole("textbox", { name: /description/i }), "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", ); await user.type(screen.getByRole("spinbutton", { name: /servings/i }), "110"); await user.click(screen.getByRole("button", { name: /save/i })); expect(screen.getAllByRole("alert")).toHaveLength(3); expect(mockSave).not.toBeCalled(); });
tsxit("should validate form fields", async () => { const mockSave = jest.fn(); const { user } = setup(<RecipeForm saveData={mockSave} />); await user.type( screen.getByRole("textbox", { name: /description/i }), "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", ); await user.type(screen.getByRole("spinbutton", { name: /servings/i }), "110"); await user.click(screen.getByRole("button", { name: /save/i })); expect(screen.getAllByRole("alert")).toHaveLength(3); expect(mockSave).not.toBeCalled(); });
This is the fundamental approach for testing any React Hook Form component, ensuring that we verify its behavior from the user's perspective. The process can be broken down into several steps:
- Create a mock
onSubmit
callback and pass it to the component. - Render the component.
- Populate the tested fields using
userEvent
. - Click the Submit button and inspect the payload of the mocked callback, verifying that it was called and contains the expected data.
Applying these guidelines to our test, we test all the fields simultaneously by providing invalid data - no name, an excessively long description, and the number of servings exceeding 10. After submitting the form, we verify that the number of error messages (rendered as div
elements with the alert
role as a part of the Field component) matches the number of fields with errors. While we could further check for specific error messages on the screen, it seems unnecessary in this case. Finally, we confirm that our mock save callback has not been called, making sure it's not possible to submit the form with invalid data.
If you're looking to improve your React Testing Library tests, you might find this article helpful: Improving React Testing Library Tests.
Testing dynamic fields
Before we proceed with testing the successful submit form scenario, it is essential to ensure that ingredient fields are correctly added and removed. Simultaneously, let's take a moment to improve the accessibility of the remove ingredient button, which currently looks like this:
tsx<Button type="button" onClick={() => remove(index)}> − </Button>
tsx<Button type="button" onClick={() => remove(index)}> − </Button>
The HTML character −
is used for the minus sign -
, which is far from optimal in terms of accessibility. A more accessible approach would be to provide actual text that describes the button's function. To address this issue, we'll use the aria-label
attribute.
tsx<Button type="button" onClick={() => remove(index)} aria-label={`Remove ingredient ${index}`} > − </Button>
tsx<Button type="button" onClick={() => remove(index)} aria-label={`Remove ingredient ${index}`} > − </Button>
This is way better, plus now we can easily query for specific remove buttons in the tests.
tsxit("should handle ingredient fields", async () => { const { user } = setup(<RecipeForm saveData={jest.fn()} />); const addButton = screen.getByRole("button", { name: /add ingredient/i }); await user.click(addButton); // Ingredient name + recipe name expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(2); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(1); await user.click(addButton); expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(3); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(2); await user.click( screen.getByRole("button", { name: /remove ingredient 1/i }), ); // Recipe name and ingredient name fields expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(2); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(1); });
tsxit("should handle ingredient fields", async () => { const { user } = setup(<RecipeForm saveData={jest.fn()} />); const addButton = screen.getByRole("button", { name: /add ingredient/i }); await user.click(addButton); // Ingredient name + recipe name expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(2); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(1); await user.click(addButton); expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(3); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(2); await user.click( screen.getByRole("button", { name: /remove ingredient 1/i }), ); // Recipe name and ingredient name fields expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(2); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(1); });
We proceed using a similar test structure and verify that ingredient fields are correctly added and removed. It's worth noting that we can still use the *ByRole
query; however, for the remove button, the aria-label
now serves as its accessible name.
Testing form submission
Finally, it's time to test the form's submit flow. We'll follow the same steps as before - filling in all the fields, submitting the form, and then verifying that our mockSave
function has been called with the expected values.
tsxit("should submit correct form data", async () => { const mockSave = jest.fn(); const { user } = setup(<RecipeForm saveData={mockSave} />); // Enter recipe name await user.type( screen.getByRole("textbox", { name: /name/i }), "Test recipe", ); // Enter recipe description await user.type( screen.getByRole("textbox", { name: /description/i }), "Delicious recipe", ); // Specify the number of servings await user.type(screen.getByRole("spinbutton", { name: /servings/i }), "4"); // Add an ingredient await user.click(screen.getByRole("button", { name: /add ingredient/i })); // Provide the ingredient's name await user.type( screen.getAllByRole("textbox", { name: /name/i })[1], "Flour", ); // Specify the ingredient's amount await user.type(screen.getByRole("textbox", { name: /amount/i }), "100 gr"); // Save the form await user.click(screen.getByRole("button", { name: /save/i })); expect(mockSave).toHaveBeenCalledWith({ name: "Test recipe", description: "Delicious recipe", amount: 4, ingredients: [{ name: "Flour", amount: "100 gr" }], }); });
tsxit("should submit correct form data", async () => { const mockSave = jest.fn(); const { user } = setup(<RecipeForm saveData={mockSave} />); // Enter recipe name await user.type( screen.getByRole("textbox", { name: /name/i }), "Test recipe", ); // Enter recipe description await user.type( screen.getByRole("textbox", { name: /description/i }), "Delicious recipe", ); // Specify the number of servings await user.type(screen.getByRole("spinbutton", { name: /servings/i }), "4"); // Add an ingredient await user.click(screen.getByRole("button", { name: /add ingredient/i })); // Provide the ingredient's name await user.type( screen.getAllByRole("textbox", { name: /name/i })[1], "Flour", ); // Specify the ingredient's amount await user.type(screen.getByRole("textbox", { name: /amount/i }), "100 gr"); // Save the form await user.click(screen.getByRole("button", { name: /save/i })); expect(mockSave).toHaveBeenCalledWith({ name: "Test recipe", description: "Delicious recipe", amount: 4, ingredients: [{ name: "Flour", amount: "100 gr" }], }); });
If you're looking for guidance on how to test file upload elements, you might find this article helpful: React Hook Form: Working with multipart form data and file uploads.
Debugging tests
Sometimes, you might encounter issues while writing tests, such as failing to find an element or not being able to simulate user interactions, which might seem to be hard to debug.
If at any point you encounter a message like TestingLibraryElementError: Unable to find an accessible element with the role ... and name ...
during testing, you can troubleshoot it in several ways:
- Ensure that you are using the correct name. If you are using a case-sensitive match, the text of the
name
attribute should match the queried element's accessible name completely. - Verify that you are using the correct role for the element. For instance, number inputs have a different role than text inputs (
spinbutton
vstextbox
). Some input types, such aspassword
ordate
, do not have an implicit ARIA role, so you should use*ByLabelText
queries in those cases. When a test fails for this reason, React Testing Library typically outputs all elements with their roles and accessible names in the terminal under theHere are the accessible roles:
section, which helps determine the appropriate queries for the elements. - If you still cannot determine why the test is failing, use the
debug
method from thescreen
object. Calling it without arguments logs the entire component in the terminal for inspection. Alternatively, you can pass in a query for the specific element you want to inspect, such asscreen.debug(screen.getByRole("spinbutton", { name: /servings/i }));
.
Conclusion
In conclusion, React Hook Form is an efficient and popular library for managing form state and validation in React applications. As important as implementing forms correctly, testing forms is equally crucial to ensure their reliability. This post explored how to use React Testing Library to test React Hook Form components. It covered the basics of setting up the testing environment, writing test cases for various scenarios, and best practices for testing forms thoroughly. By testing the RecipeForm
component, we demonstrated the fundamental approach for testing any React Hook Form component, ensuring that we verify its behavior from the user's perspective. Testing form fields, submitting correct form data, and testing ingredient fields' removal and addition were among the scenarios we covered.
To summarize, if you're facing challenges while testing forms with React Testing Library and are unsure where to begin, following these basic steps should set you on the right path:
- Create a mock
onSubmit
callback and pass it to the component. - Render the component.
- Populate the tested fields using
userEvent
. - Click the Submit button and inspect the payload of the mocked callback, verifying that it was called and contains the expected data.
References and resources
- Beyond Console.log: Debugging Techniques In JavaScript
- Build Dynamic Forms with React Hook Form
- Build a Multistep Form With React Hook Form
- Create React App
- Creating Accessible Form Components with React
- Form Validation with React Hook Form
- GitHub repository with the code for the tutorial
- Improving React Testing Library Tests
- Jest
- Managing Forms With React Hook Form
- React Hook Form
- React Testing Library
- Simplifying Form Rendering In React with Field Component Abstraction
- jest-dom
- user-event setup
- user-event
- w3.org: Accessible name
- w3.org: HTML-ARIA