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 theuser.clickcall means we don't need to depend on when theuser.clickpromise resolves. - But the button doesn't become disabled right away, so we need to use
waitForto 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();
}),
])
});
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.