Skip to content

Instantly share code, notes, and snippets.

@nandanmen
Last active August 4, 2022 20:06
Show Gist options
  • Save nandanmen/a1d758586a557a7ca9723a19e42a0f16 to your computer and use it in GitHub Desktop.
Save nandanmen/a1d758586a557a7ca9723a19e42a0f16 to your computer and use it in GitHub Desktop.
A look at some of the implications of making `userEvent` calls asynchronous.

Asynchronous userEvent

One of the breaking changes of userEvent in v14 is the change to asynchronous user events:

userEvent.click() // <-- returns a Promise!

This sounds like a pretty small change, but let's take a look at some of the implications.

Consider this Counter component:

export const Counter = (props) => {
  const [count, setCount] = React.useState(0);
  return (
    <div>
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        Inc
      </button>
      <p>{count}</p>
    </div>
  );
};

When we click on the button, we expect count to be 1. The following test checks for this to be true:

const mount = () => {
  const user = userEvent.setup();
  render(<Counter />);
  return user;
};

it('increments', () => {
  const user = mount();
  user.click(screen.getByRole('button'));
  expect(screen.getByText('1')).toBeVisible();
});

Except this test now fails because user.click is asynchronous. By the time we reach the expect call, React hasn't had a chance to update yet so it still shows 0 on the screen.

Let's try awaiting the user.click then:

it('increments', async () => {
  const user = mount();
  await user.click(screen.getByRole('button'));
  expect(screen.getByText('1')).toBeVisible();
});

The tests pass!

Let's try another case. This time, our Counter component needs to update the count on the server, so we need to disable the button while we're making that request:

export const Counter = (props) => {
  const [count, setCount] = React.useState(0);
  const [loading, setLoading] = React.useState(false);
  return (
    <div>
      <button
        disabled={loading}
        onClick={() => {
          setLoading(true);
          updateServer(count + 1).then((val) => {
            setLoading(false);
            setCount(val);
          });
        }}
      >
        Inc
      </button>
      <p>{count}</p>
    </div>
  );
};

With this new feature, we want to add a new test that checks that the button is disabled while the call is happening:

it('disables the button', async () => {
  const user = mount();
  await user.click(screen.getByRole('button'));
  expect(screen.getByRole('button')).toBeDisabled();
});

Except it doesn't work:

Received element is not disabled:
  <button />

  26 |       const user = mount();
  27 |       await user.click(screen.getByRole('button'));
> 28 |       expect(screen.getByRole('button')).toBeDisabled();
     |                                          ^
  29 |     });
  30 |   });
  31 | });

What if we remove the await?

Received element is not disabled:
  <button />

  26 |       const user = mount();
  27 |       user.click(screen.getByRole('button'));
> 28 |       expect(screen.getByRole('button')).toBeDisabled();
     |                                          ^
  29 |     });
  30 |   });
  31 | });

Nope.

Let's put the await back and see what screen.debug() tells us.

it('disables the button', async () => {
  const user = mount();
  await user.click(screen.getByRole('button'));
  screen.debug();
  expect(screen.getByRole('button')).toBeDisabled();
});
<body>
  <div>
    <div>
      <button>
        Inc
      </button>
      <p>
        1
      </p>
    </div>
  </div>
</body>

The displayed count is 1! This means the await is making us wait all the way to when the server call completes. That's not what we want!

This broke my brain for a bit because I had previously assumed that awaiting the .click means waiting until the click event finishes, and nothing more. At this point, I'm still not sure when the .click promise resolves.

Anyways, back to the problem. If putting await before user.click doesn't work, what should we do? Maybe a waitFor?

it('disables the button', async () => {
  const user = mount();
  user.click(screen.getByRole('button'));
  await waitFor(() => {
    expect(screen.getByRole('button')).toBeDisabled();  
  })
});

Success!

PASS  src/components/counter/count.test.tsx
Counter
  ✓ starts with 0 (93 ms)
  ✓ increments (68 ms)
  ✓ disables the button (48 ms)

I believe this works because:

  • Not awaiting the user.click call means we don't need to depend on when the user.click promise resolves.
  • But the button doesn't become disabled right away, so we need to use waitFor to wait for that to happen.

There's one last thing we need to do. Since user.click is asynchronous, it's possible that its promise will resolve after the test completes. To prevent this leak, we can use Promise.all to:

  • Make sure both Promises resolve within this test, and;
  • Both Promises run in parallel, so the test will still pass.
it('disables the button', async () => {
  const user = mount();
  await Promise.all([
    user.click(screen.getByRole('button')),
    waitFor(() => {
      expect(screen.getByRole('button')).toBeDisabled();
    }),
  ])
});
@nandanmen
Copy link
Author

There's one last thing we need to do. Since user.click is asynchronous, it's possible that its promise will resolve after the test completes. To prevent this leak, we can use Promise.all to:

This was a suggestion by @ph-fritsche, although I'm not too sure what the implications are of having the Promise resolve outside of this test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment