Upgrading Testing Library's user-event to version 14

react

React Testing Library octopus logo, React logo, and Jest logo

In this article I’ll go over how to upgrade Testing Library’s user-event package to version 14. I’ll give a brief refresher on why we use user-event in the first place, take a look at what’s new in version 14, and finally go over the details of how I did the upgrade. I’ll include a couple examples of tests that broke when upgrading that will hopefully help make your transition smoother.

What is user-event?

As explained in the user-event docs:

"user-event is a companion library for Testing Library that simulates user interactions by dispatching the events that would happen if the interaction took place in a browser."

To sum up, this library helps to closely mimic how the user interacts with the browser and makes for better tests.

Why should we use it?

Previously, fireEvent was the API for testing user interactions in the browser. However, user-event does it one better by trying to simulate all the interactions that happen when a user takes an action in the browser. Kent C. Dodds compares fireEvent.change to userEvent.type in a blog post:

"`fireEvent.change` will simply trigger a single change event on the input. However the type call will trigger `keyDown`, `keyPress`, and `keyUp` events for each character as well. It's much closer to the user's actual interactions."

Some of these interactions will be missed with fireEvent, and that could be problematic for your tests. As mentioned in the docs, there are still a few rare cases where fireEvent may be needed, but in most cases user-event should be used.

What’s new in user-event 14?

You can see most of the changes that come with version 14 on the releases page. Besides lots of other smaller features and bug fixes, the biggest change is the setup API, which allows you to configure options for each instance that the user-event library is used. More on this below.

Before you attempt an upgrade, be sure to take a good look at the “Breaking Changes” section of the releases page mentioned above to see how you might be affected. I’ll provide an example of a breaking change after looking at how to start the upgrade.

How to upgrade

Upgrade the package with yarn by running: yarn upgrade @testing-library/user-event

Or upgrade via npm: npm update @testing-library/user-event

The first thing I’d do after upgrading is to run your tests to get an idea of how much work you have ahead of you:

yarn test or npm test

In my case, after upgrading I had about 18% of tests failing. Most tests should pass because calling userEvent directly calls the new setup API under the hood. This call to setup behind the scenes was included specifically to help make the transition to version 14 easier. I’ll show you how we should use setup going forward.

To start however, it might be helpful to look at how we did things previously. Your tests might’ve looked like the following, where we import and call userEvent directly:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("clicking checkbox", () => {
  render(<ExampleComponent />);

  userEvent.click(screen.getByRole("checkbox"));

  expect(screen.getByTestId("example-id")).toBeInTheDocument();
});

Now that we have a reference of what we were doing before, let’s get to how we should do things going forward. As I mentioned above, the setup API is likely what will cause the most amount of code changes when you upgrade. You’ll want to create a test utility like the following code I put into a file I named setup.ts:

import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Options } from "@testing-library/user-event/dist/types/options";
import { ReactElement } from "react";

export const setup = (ui: ReactElement, options?: Options) => ({
  user: userEvent.setup(options),
  ...render(ui),
});

This utility accepts a React component and some options, instantiates a user with userEvent.setup and passes options as an argument to setup, and then uses render to render the component. With this test utility created, you can then import it and use it in your tests like so:

import { screen } from "@testing-library/react";
// make sure to correct the import path for your app
import { setup } from "@/util/testUtils/setup";

test("clicking checkbox", async () => {
  const { user } = setup(<ExampleComponent />);

  await user.click(screen.getByRole("checkbox"));

  expect(screen.getByTestId("example-id")).toBeInTheDocument();
});

Notice a few differences from how we did things previously? Before we would import and call userEvent directly and call render with the component. Now we destructure user from setup, and call any user-event actions on user. Also notice we had to add an async in the test setup options, and are adding an await before user.click. Previously when calling userEvent.click, it would automatically wrap an await around the action. Not anymore. Now you have to be explicit about calling await. In my experience upgrading, although there may be a few rare cases where an await isn’t needed, you can pretty much always assume you’ll need it and default to using it.

Configuring setup options

There are many setup options available for use. Let’s take a look at an example of the options and how it affects our tests.

Again let’s start with what we were doing before. Let’s say you wanted to type into two different inputs, but skip the click event that would usually happen by default:

render(<SomeComponent />);

userEvent.type(
  screen.getByRole("textbox", { name: "someTextbox" }),
  "something",
  { skipClick: true }
);

userEvent.type(
  screen.getByRole("textbox", { name: "anotherTextbox" }),
  "anything",
  { skipClick: true }
);

Now, using setup we’d do the following:

const { user } = setup(<SomeComponent />, { skipClick: true });

await user.type(
  screen.getByRole("textbox", { name: "someTextbox" }),
  "something"
);

await user.type(
  screen.getByRole("textbox", { name: "anotherTextbox" }),
  "anything"
);

Instead of passing a skipClick option for every single event, { skipClick: true } is applied to the entire instance of setup. This can be handy for tricky situations like needing to change the keyboard layout for an entire test.

How I fixed a test using a setup option

Remember how I said to take a close look at the “Breaking Changes” section on the releases page? I did not and struggled to figure out why one of our existing tests wasn’t working.

I had a test where upload was used to upload a CSV to an input, and we wanted to test that an error would occur when trying to upload a JSON file instead of a CSV file:

const mockFile = new File([blob], "test.json", {
  type: "application/json",
});

const { user } = setup(<ImportQuoteRequests {...props} />);

await user.upload(screen.getByTestId("#CSVReader"), mockFile);

await waitFor(() => {
  expect(props.setErrors).toHaveBeenCalledWith([
    "The file you've chosen couldn't be recognized.",
  ]);
});

Before the upgrade this test was working, and the only thing that really had changed was the usage of setup …so what gives? It turns out that one of the breaking changes listed was that the default for upload had changed from allowing any file type regardless of the input’s accept property, to only allowing what was in the accept property by default. This actually more closely resembles the user interaction, because I tried uploading a different file type myself in the UI and wasn’t able to do it. But I still wanted to test this to get more coverage, and surely if there’s a will to upload the wrong file type there’s a way.

The answer was to use the applyAccept setup option and set it to false:

const { user } = setup(<ImportQuoteRequests {...props} />, {
  applyAccept: false,
});

That was the only thing that needed to change. So beware of the power of setup configurations.

How I fixed a test using keyboard

Let’s take a look at another test that broke after upgrading and how I went about solving it. I had a test where I wanted to make sure someone couldn’t submit an invalid date via an input. Previously I was using userEvent.type to input an invalid date:

userEvent.type(dateInput, "asdf");

This one was a bit tricky because in the UI, when you click the input, a datepicker modal pops up and you’d have to either hit the escape key or tab your way to the input to get the modal to go away while still being focused on the input. I tried going the escape key route, and keyboard came to the rescue:

await user.click(dateInput);
await user.keyboard("{Escape}asdf");

This simulates the user hitting the escape key and then ‘asdf’, which helped me test invalid data for this particular input. This again more closely resembles the actual user interaction compared to userEvent.type, and therefore is providing better quality test coverage.

Refactor opportunities

I mostly just focused on fixing the breaking tests after upgrading, but since I was refactoring a lot of these tests to work with the new library version anyways, I looked for any easy refactors I could make along the way. If you haven’t stumbled across this yet, bookmark this blog post by Kent C. Dodds: “Common mistakes with React Testing Library”.

A couple favorites of mine were to use screen instead of destructuring, and to use find* queries instead of waitFor. Again, not necessary, but perhaps a good opportunity to do some cleanup and use queries that return better error messages, saving you time when debugging your tests.

Conclusion

To sum up, user-event version 14 comes with lots of great improvements and increases your testing quality. While you will likely face some breaking changes, the upgrade is well worth it. Hope this helps you in your upgrade!