Testing React Hook Form With React Testing Library

Updated on · 8 min read
Testing React Hook Form With React Testing Library

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.

bash
npm i -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
bash
npm 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.

tsx
import { 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(); });
tsx
import { 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.

tsx
interface 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); }; //... };
tsx
interface 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.

js
import { 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), }; }
js
import { 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.

tsx
it("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(); });
tsx
it("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:

  1. Create a mock onSubmit callback and pass it to the component.
  2. Render the component.
  3. Populate the tested fields using userEvent.
  4. 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)}> &#8722; </Button>
tsx
<Button type="button" onClick={() => remove(index)}> &#8722; </Button>

The HTML character &#8722; 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}`} > &#8722; </Button>
tsx
<Button type="button" onClick={() => remove(index)} aria-label={`Remove ingredient ${index}`} > &#8722; </Button>

This is way better, plus now we can easily query for specific remove buttons in the tests.

tsx
it("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); });
tsx
it("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.

tsx
it("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" }], }); });
tsx
it("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:

  1. 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.
  2. Verify that you are using the correct role for the element. For instance, number inputs have a different role than text inputs (spinbutton vs textbox). Some input types, such as password or date, 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 the Here are the accessible roles: section, which helps determine the appropriate queries for the elements.
  3. If you still cannot determine why the test is failing, use the debug method from the screen 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 as screen.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:

  1. Create a mock onSubmit callback and pass it to the component.
  2. Render the component.
  3. Populate the tested fields using userEvent.
  4. 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