Testing React Hook Form With React Testing Library
Tags: react, testing, tutorial
Published: 2020-11-08 09:14:09

In the [previous post](https://claritydev.net/blog/managing-forms-with-react-hook-form/) we have added a basic recipe form using [React Hook Form](https://react-hook-form.com/). 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](https://testing-library.com/docs/react-testing-library) (RTL) as a testing framework of choice, since it works really well with the Hook Form and is a recommended library to test it with. 

Let's start, as usual, by installing the required packages. 
```
npm install --save-dev @testing-library/react @testing-library/jest-dom
```
Apart from the testing library, we also add [jest-dom](https://github.com/testing-library/jest-dom) to be able to use custom Jest matchers. Now we can start writing tests for the Recipe component. Let's create **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();
});
```
Those familiar with the RTL might notice that we're not using `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 the accessibility concerns in mind, the `getByRole` query will be sufficient in most of the cases. To be able to effectively use `*ByRole` queries, it's necessary to understand what [ARIA role](https://www.w3.org/TR/html-aria/#docconformance) 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](https://www.w3.org/TR/accname-1.1/), which is used by assistive technologies to identify HTML elements. There are several rules that browsers use to compute accessible name. For our purposes, 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 label has `for` attribute corresponding to the input's `id`. Now we see how having accessible forms makes testing them easier. For button, provided there's 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. 

Now that we have basics tests done, let's move on to test field validation. Before that, we'll slightly modify the form component by adding `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);
  };

//...
}
```
Normally `saveData` would make an API call to send the form data to the server or do some data processing. For the purposes of field validation we are only interested if this function is called or not, since if any of the fields are invalid, 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();
});
```
We test all the fields at once by providing invalid data - no name, too long description and the number of serving 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 `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. 

Before we move onto 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 accessibility of the remove ingredient button, which currently looks like this:
```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 accessibility point view. It would be much better if we could provide an actual text that describes what this button does. To fix this we'll use `aria-label` attribute. 
```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 button 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);
});
```
We continue with the similar text structure and validate that ingredient fields are added and removed correctly. It's worth noting that we can still use `*ByRole` query, only that in the case of remove button `aria-label` is now its accessible name. 

Finally it's time to test the form's submit flow. In order to test it, we fill 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" }]
    })
  );
});
```
Important to note here that we're using `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.