Testing React Hook Form With React Testing Library
Updated on · 5 min read|
In the previous post we have added a basic recipe form using React Hook Form. It would be a good idea to add some unit tests for it, to make sure that the form works properly and to catch any future regressions. We'll use React Testing Library (RTL) as a testing framework of choice since it works well with the Hook Form and is a recommended library to test it with.
Let's start, as usual, by installing the required packages.
bashnpm install --save-dev @testing-library/react @testing-library/jest-dom
bashnpm install --save-dev @testing-library/react @testing-library/jest-dom

Apart from the testing library, we also add jest-dom to be able to use custom Jest matchers. Now we can start writing tests for the Recipe component. Let's create a Recipe.test.js file and add the first test checking that basic fields are properly rendered.
jsxit("should render the basic fields", () => { render(<Recipe />); 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(); });
jsxit("should render the basic fields", () => { render(<Recipe />); 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(); });
Those familiar with the RTL might notice that we're not using the getByText
query here and instead default to getByRole
. The latter is preferred because it resembles more closely how the users interact with the page - both using mouse/visual display and assistive technologies. This is one of the particularly compelling reasons to use RTL - if the code is written with accessibility concerns in mind, the getByRole
query will be sufficient in most cases. To be able to effectively use *ByRole
queries, it's necessary to understand what ARIA role each HTML element has. In our form we use h1
, which has heading role, text input
and textarea
with textbox role, number input
with spinbutton role, and button
with 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. It has to be noted that this is not the name attribute we give to the 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, the input's accessible name is computed from its associated elements, in this case, its label. However, for this to work, the label has to be properly associated with the input, e.g. the input is wrapped in the label or the label has a for
attribute corresponding to the input's id
. Now we see how having accessible forms makes testing them easier. For the button, provided there are no aria-label
or associated aria-labelledby
attributes (which take precedence over other provided and native accessible names), the accessible name is computed using its content. In this case, it's Add ingredient and Save texts. Additionally, we can use regex syntax to match the name, which is convenient, for example, for case-insensitive matches.
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.
Now that we have the basic tests done, let's move on to test field validation. Before that, we'll slightly modify the form component by adding a saveData
prop, which will be called on form submit. This way we can test if it has been called and inspect the arguments.
jsxexport const Recipe = ({ saveData }) => { const { register, handleSubmit, errors, control } = useForm(); const { fields, append, remove } = useFieldArray({ name: "ingredients", control }); const submitForm = formData => { saveData(formData); }; //... }
jsxexport const Recipe = ({ saveData }) => { const { register, handleSubmit, errors, control } = useForm(); const { fields, append, remove } = useFieldArray({ name: "ingredients", control }); const submitForm = formData => { saveData(formData); }; //... }
Normally saveData
would make an API call to send the form data to the server or do some data processing. For field validation, we are only interested if this function is called or not, since if any of the fields are invalid, the form's onSubmit
callback is not invoked.
jsxit("should validate form fields", async () => { const mockSave = jest.fn(); render(<Recipe saveData={mockSave} />); fireEvent.input(screen.getByRole("textbox", { name: /description/i }), { target: { value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." } }); fireEvent.input(screen.getByRole("spinbutton", { name: /servings/i }), { target: { value: 110 } }); fireEvent.submit(screen.getByRole("button", { name: /save/i })); expect(await screen.findAllByRole("alert")).toHaveLength(3); expect(mockSave).not.toBeCalled(); });
jsxit("should validate form fields", async () => { const mockSave = jest.fn(); render(<Recipe saveData={mockSave} />); fireEvent.input(screen.getByRole("textbox", { name: /description/i }), { target: { value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." } }); fireEvent.input(screen.getByRole("spinbutton", { name: /servings/i }), { target: { value: 110 } }); fireEvent.submit(screen.getByRole("button", { name: /save/i })); expect(await screen.findAllByRole("alert")).toHaveLength(3); expect(mockSave).not.toBeCalled(); });
We test all the fields at once by providing invalid data - no name, too long description, and the number of servings that is above 10. Then we submit the form and check that the number of error messages (rendered as span
with alert
role) is the same as the number of fields with errors. We could go even further and check that specific error messages are rendered on the screen, but that seems a bit excessive here. Since submitting the form results in state changes and re-rendering, we need to use the findAllByRole
query combined with await
to get the error messages after the form has been re-rendered. Lastly, we confirm that our mock save callback has not been called.
If you're aiming to improve your React Testing Library tests, you might find this article helpful: Improving React Testing Library Tests.
Before we move on to testing the whole submit form flow, it would be nice to verify that ingredient fields are properly added and removed. At the same time let's take a moment to improve the accessibility of the remove ingredient button, which currently looks like this:
jsx<Button type="button" onClick={() => remove(index)}> − </Button>
jsx<Button type="button" onClick={() => remove(index)}> − </Button>
The HTML character −
is used for the minus sign -
, which is far from optimal from an accessibility point of view. It would be much better if we could provide actual text that describes what this button does. To fix this we'll use the aria-label
attribute.
jsx<Button type="button" onClick={() => remove(index)} aria-label={`Remove ingredient ${index}`} > − </Button>
jsx<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.
jsxit("should handle ingredient fields", () => { render(<Recipe />); const addButton = screen.getByRole("button", { name: /add ingredient/i }); fireEvent.click(addButton); // Ingredient name + recipe name expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(2); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(1); fireEvent.click(addButton); // Ingredient name + recipe name expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(3); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(2); fireEvent.click( screen.getByRole("button", { name: /remove ingredient 1/i }) ); expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(2); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(1); });
jsxit("should handle ingredient fields", () => { render(<Recipe />); const addButton = screen.getByRole("button", { name: /add ingredient/i }); fireEvent.click(addButton); // Ingredient name + recipe name expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(2); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(1); fireEvent.click(addButton); // Ingredient name + recipe name expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(3); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(2); fireEvent.click( screen.getByRole("button", { name: /remove ingredient 1/i }) ); expect(screen.getAllByRole("textbox", { name: /name/i })).toHaveLength(2); expect(screen.getAllByRole("textbox", { name: /amount/i })).toHaveLength(1); });
We continue with a similar text structure and validate that ingredient fields are added and removed correctly. It's worth noting that we can still use the *ByRole
query, only that in the case of the remove button aria-label
is now its accessible name.

Finally, it's time to test the form's submit flow. To test it, we fill in all the fields, submit the form and then validate that our mockSave
function has been called with expected values.
jsxit("should submit correct form data", async () => { const mockSave = jest.fn(); render(<Recipe saveData={mockSave} />); fireEvent.input(screen.getByRole("textbox", { name: /name/i }), { target: { value: "Test recipe" } }); fireEvent.input(screen.getByRole("textbox", { name: /description/i }), { target: { value: "Delicious recipe" } }); fireEvent.input(screen.getByRole("spinbutton", { name: /servings/i }), { target: { value: 4 } }); fireEvent.click(screen.getByRole("button", { name: /add ingredient/i })); fireEvent.input(screen.getAllByRole("textbox", { name: /name/i })[1], { target: { value: "Flour" } }); fireEvent.input(screen.getByRole("textbox", { name: /amount/i }), { target: { value: "100 gr" } }); fireEvent.submit(screen.getByRole("button", { name: /save/i })); await waitFor(() => expect(mockSave).toHaveBeenCalledWith({ name: "Test recipe", description: "Delicious recipe", amount: 4, ingredients: [{ name: "Flour", amount: "100 gr" }] }) ); });
jsxit("should submit correct form data", async () => { const mockSave = jest.fn(); render(<Recipe saveData={mockSave} />); fireEvent.input(screen.getByRole("textbox", { name: /name/i }), { target: { value: "Test recipe" } }); fireEvent.input(screen.getByRole("textbox", { name: /description/i }), { target: { value: "Delicious recipe" } }); fireEvent.input(screen.getByRole("spinbutton", { name: /servings/i }), { target: { value: 4 } }); fireEvent.click(screen.getByRole("button", { name: /add ingredient/i })); fireEvent.input(screen.getAllByRole("textbox", { name: /name/i })[1], { target: { value: "Flour" } }); fireEvent.input(screen.getByRole("textbox", { name: /amount/i }), { target: { value: "100 gr" } }); fireEvent.submit(screen.getByRole("button", { name: /save/i })); await waitFor(() => expect(mockSave).toHaveBeenCalledWith({ name: "Test recipe", description: "Delicious recipe", amount: 4, ingredients: [{ name: "Flour", amount: "100 gr" }] }) ); });
Important to note here that we're using the waitFor
utility to test the result of asynchronous action (submitting the form). It will fire the provided callback after the async action has been completed.
Now we have a quite comprehensive unit test suite that validates the form's behavior.
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.
