Tom
Tom

Reputation: 9137

How to use user-event library in conjunction with Jest's fake timers?

I'm writing Jest tests for a React component, and using the @testing-library/user-event library to simulate user interaction. This works great, except in tests that use Jest's fake timers.

Here's a sample test:

it(`fires onClick prop function when the button is clicked`, async () => {
  // jest.useFakeTimers()

  let propFn = jest.fn()

  let app = RTL.render(
    <SampleComp onClick={propFn} />
  )

  await userEvent.click(app.queryByText('Test button')!)

  expect(propFn).toHaveBeenCalled()

  // jest.useRealTimers()
})

And here's the component:

function SampleComp({ onClick }) {
  // a simple bridge for debugging
  function _onClick(e) {
    console.log(`_onClick invoked`)
    return onClick(e)
  }

  return (
    <button onClick={_onClick}>
      Test button
    </button>
  )
}

Without fake timers, the test runs in a fraction of a second and passes. With the fake timers, the test times out and then fails:

  ● fires onClick prop function when the button is clicked

    thrown: "Exceeded timeout of 5000 ms for a test.
    Use jest.setTimeout(newTimeout) to increase the timeout value, if this is a long-running test."

It also fails to emit the debug line, so evidently the click event is never fired.

I've done a whole bunch of debugging, and have determined that the root cause is that the user-event library relies on a setTimeout internally to create a brief delay between events.

Yes, "events" plural -- recall that the value-proposition of the user-event library is:

fireEvent dispatches DOM events, whereas user-event simulates full interactions, which may fire multiple events and do additional checks along the way.
-- user-event docs

In this case, user-event creates two events for me:

  1. a mouse movement1
  2. the actual click

1Although user-event's internal event object for the first event has only a target and no type, it makes sense to me that the first event would be either mouse-move or mouse-over on the button. Regardless: I've verified that the list of actions user-event creates internally does have 2 items, and the second one is clearly marked as "[MouseLeft]".

The delay is inserted between events by user-event's pointerAction function. In my case, user-event stalls out during the delay between events 1 and 2.

The timeout is created in user-event's wait module:

new Promise<void>(resolve => globalThis.setTimeout(() => resolve(), delay))

I've verified that this is the problem by monkeypatching that line inside my node_modules to simply resolve immediately without a timer, like so:

new Promise<void>(resolve => resolve())

Unfortunately, my application code has some important timers, and it won't do to make the test suite wait for them in real time. And even if I could, I suspect folks who find this question later might not have that freedom.

It does not work to run Jest's fake timers to permit user-event to proceed, like so:

it(`fires onClick prop function when the button is clicked`, async () => {
  jest.useFakeTimers()

  let propFn = jest.fn()

  let app = RTL.render(
    <SampleComp onClick={propFn} />
  )

  let userAction = userEvent.click(app.queryByText('Test button')!)

  jest.runOnlyPendingTimers() // no good
  jest.runAllTimers() // also no good

  await userAction

  expect(propFn).toHaveBeenCalled()

  jest.useRealTimers()
})

So, how is one supposed to use Jest's fake timers in conjunction with the user-event library?


In this case, versions do seem to matter: this test worked great with earlier versions of React, Jest, and user-event. Here are the versions I'm using now (where this breaks), as well as the versions I was using (where this works):

Library Current
(breaks)
Previous
(worked)
jest v27.5.1 v26.6.3
react v18.1.0 v16.13.1
@testing-library/user-event v14.3.0 v12.8.3

I should note that it's not possible to change the package versions being used. I need a solution that works with the specific versions listed in the "Current" column.

Note: I think this problem is not specific to React, and folks using Vue or Svelte or anything else would have the same problem as long as they're using Jest's fake timers + user-event (which are both framework agnostic). So, I'm not tagging it as React.

Upvotes: 14

Views: 4754

Answers (2)

Max Smolens
Max Smolens

Reputation: 3811

Update: In user-event v14.1 and later you can use the advanceTimers option to advance the fake timers:

const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })

or, more explicitly:

const user = userEvent.setup({
    advanceTimers: (delay) => {
        jest.advanceTimersByTime(delay)
    }
})

user-event calls the advanceTimers function when simulating the delay between input events. By calling jest.advanceTimersByTime, we advance the fake timers and the test doesn't time out.

Previous answer: (no longer recommended)

You can disable the delay using the user-event setup function:

const user = userEvent.setup({ delay: null })

Make sure to then use the instance returned by setup():

await user.click(app.queryByText('Test button')!)

Upvotes: 10

Marek Rozmus
Marek Rozmus

Reputation: 968

Did you try this:

const ue = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });

In my case it didn't work if it was not set up. More here Mocking setTimeout with Jest

Upvotes: 7

Related Questions