Alex K.
Posted on November 8, 2020
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.
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.
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)}>
−
</Button>
The HTML character −
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}`}
>
−
</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.
Posted on November 8, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 29, 2024
April 7, 2023