Testing React Hook Form With React Testing Library

react react-hook-form react-testing-library

Image credit: Unsplash

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 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 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.

import React from "react";
import { screen, render } from "@testing-library/react";
import "@testing-library/jest-dom";
import { Recipe } from "./Recipe";

describe("RecipeForm", () => {
  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 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 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. 

// Recipe.js

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. 

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:

<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. 

<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. 

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.

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.