Testing React Hook Form With React Testing Library

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

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.

bash
npm install --save-dev @testing-library/react @testing-library/jest-dom
bash
npm 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.

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

jsx
export const Recipe = ({ saveData }) => { const { register, handleSubmit, errors, control } = useForm(); const { fields, append, remove } = useFieldArray({ name: "ingredients", control }); const submitForm = formData => { saveData(formData); }; //... }
jsx
export 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.

jsx
it("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(); });
jsx
it("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)}> &#8722; </Button>
jsx
<Button type="button" onClick={() => remove(index)}> &#8722; </Button>

The HTML character &#8722; 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}`} > &#8722; </Button>
jsx
<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.

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

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