user-event icon indicating copy to clipboard operation
user-event copied to clipboard

`userEvent.click()` fails when used with `vi.useFakeTimers()`, all available solutions are not working

Open xsjcTony opened this issue 2 years ago • 11 comments

Reproduction example

https://stackblitz.com/edit/vitejs-vite-askvcq?file=src/tests/App.test.tsx

Prerequisites

Describe the bug

I have a React component which is a timer (minimal reproduction), that starts automatically after mounted, and there's a button to RESET the timer.

In the test, I'm using vi.useFakeTimers() and await vi.advanceTimersByTimeAsync(500) to test the timer segmentally.

However, I'm not able to use await user.click() to click the button.

This is a known issue with fake timers, however, none of the existing solutions works.

None of these works

const user = userEvent.setup({
  advanceTimers: vi.advanceTimersByTime,
  // advanceTimers: vi.advanceTimersByTimeAsync
  // advanceTimers: vi.advanceTimersByTime.bind(vi)
  // advanceTimers: vi.advanceTimersByTimeAsync.bind(vi)
  // delay: null,
})

And I cannot use vi.useRealTimers() before clicking button, since it will break the fake timer to further test the component's reset functionality.


Those solutions above are all based on Jest since almost all resources on the internet are for Jest. But since I've never used Jest, so I'm not sure if it's working in Jest, and is this an issue with @testing-library/user-event or Vitest

Expected behavior

The button is successfully clicked

Actual behavior

It makes the test timed out

User-event version

14.0.0

Environment

System:
  OS: Windows 10 10.0.22000
  CPU: (8) x64 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
  Memory: 9.23GB / 31.69GB
Binaries:
  Node: 16.18.0 - C:\Program Files\nodejs\node.EXE
  npm: 8.19.2 - C:\Program Files\nodejs\npm.CMD
Browsers:
  Edge: Spartan (44.22000.120.0), Chromium (112.0.1722.39)
  Internet Explorer: 11.0.22000.120
npmPackages:
  @vitejs/plugin-react: ^3.1.0 => 3.1.0
  vite: ^4.2.1 => 4.2.1
  vitest: ^0.30.1 => 0.30.1
  @testing-library/jest-dom: ^5.16.5 => 5.16.5
  @testing-library/react: ^14.0.0 => 14.0.0
  @testing-library/user-event: ^14.4.3 => 14.4.3
  jsdom: ^21.1.1 => 21.1.1
  react: ^18.2.0 => 18.2.0
  react-dom: ^18.2.0 => 18.2.0

Additional context

Please also refer to https://github.com/vitest-dev/vitest/issues/3184

xsjcTony avatar Apr 12 '23 10:04 xsjcTony

This is an issue with @testing-library/react. See https://github.com/testing-library/react-testing-library/issues/1197

Trick this piece of code into recognizing your environment as Jest.

globalThis.jest = 'neitherUndefinedNorNull'

ph-fritsche avatar Apr 12 '23 10:04 ph-fritsche

@ph-fritsche Thanks a lot, I got what the problem is.

But regarding your solutions, I think for a temporary workaround, this makes more sense to me https://github.com/wojtekmaj/react-async-button/commit/2d26f217a375b7020ddf42f76891254586fc3ce4

Is there any ETA to fix this issue? since I personally don't like temp workaround to be there forever in my code🤣

Regarding the fix, just like letting users pass advanceTimer option in userEvent.setup(), instead of using jest.advanceTimersByTime, this should be testing-framework agnostic, since there are also some ppl like me who had never used Jest

xsjcTony avatar Apr 12 '23 11:04 xsjcTony

In your test suites using fake timers

import { beforeAll, vi, describe } from 'vitest';

describe('this suite uses fake timers', () => {
  // Temporarily workaround for bug in @testing-library/react when use user-event with `vi.useFakeTimers()`
  beforeAll(() => {
    const _jest = globalThis.jest;
  
    globalThis.jest = {
      ...globalThis.jest,
      advanceTimersByTime: vi.advanceTimersByTime.bind(vi)
    };
  
    return () => void (globalThis.jest = _jest);
  });
})

xsjcTony avatar Apr 13 '23 02:04 xsjcTony

I ran into same problem today. My work around:

  beforeEach(() => {
    vi.useFakeTimers()

    globalThis.jest = {
      advanceTimersByTime: vi.advanceTimersByTime.bind(vi),
    }
  })

  beforeAll(() => {
    vi.useRealTimers()
  })
  
 test('given a new recurring question submitted: form data contains only question text', async () => {
    ...
    const user = userEvent.setup({
      advanceTimers: vi.advanceTimersByTime.bind(vi),
    })
  })

This worked but causes a Warning: An update to RouterProvider inside a test was not wrapped in act(...). warning.

Would appreciate if y'all fixed this. Testing library should work out of the box with Vitest imo.

Full test file
import { unstable_createRemixStub as createRemixStub } from '@remix-run/testing'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import { CreateQuestionFormComponent } from './create-question-form-component'

describe('CreateQuestionForm component', () => {
  beforeEach(() => {
    vi.useFakeTimers()

    globalThis.jest = {
      advanceTimersByTime: vi.advanceTimersByTime.bind(vi),
    }
  })

  beforeAll(() => {
    vi.useRealTimers()
  })

  test('given a new recurring question submitted: form data contains only question text', async () => {
    const date = new Date('2022-10-20T01:00:00.000Z')
    vi.setSystemTime(date)

    let formData: FormData | undefined
    const RemixStub = createRemixStub([
      {
        path: '/',
        element: <CreateQuestionFormComponent />,
        action: async ({ request }) => {
          formData = await request.formData()
          return null
        },
      },
    ])

    const user = userEvent.setup({
      advanceTimers: vi.advanceTimersByTime.bind(vi),
    })

    render(<RemixStub />)

    const questionText = 'Did you go to bed between 8 and 9PM?'
    await user.type(screen.getByLabelText('What is the recurring question?'), questionText)
    await user.click(screen.getByRole('button', { name: /submit/i }))

    const formEntries = Object.fromEntries(formData.entries())
    expect(formEntries).toEqual({
      text: questionText,
      timestamp: date.toISOString(),
      utcOffsetInMinutes: String(date.getTimezoneOffset()),
    })
  })

  test('given form render: has cancel link to back to /questions', async () => {
    const RemixStub = createRemixStub([
      {
        path: '/',
        element: <CreateQuestionFormComponent />,
      },
    ])
    render(<RemixStub />)

    expect(screen.getByRole('link', { name: /cancel/i })).toHaveAttribute('href', '/questions')
  })

  test('given click submit before input text: does not submit form', async () => {
    let formData: FormData | undefined
    const RemixStub = createRemixStub([
      {
        path: '/',
        element: <CreateQuestionFormComponent />,
        action: async ({ request }) => {
          formData = await request.formData()
          return null
        },
      },
    ])

    const user = userEvent.setup({
      advanceTimers: vi.advanceTimersByTime.bind(vi),
    })
    render(<RemixStub />)

    await user.click(screen.getByRole('button', { name: /submit/i }))
    expect(formData).toEqual(undefined)
  })
})

iulspop avatar May 27 '23 23:05 iulspop

Should this be added to documentation somewhere? I spent a few hours trying to get my tests with fake timers migrated from Jest to Vite. This solved most of my issues. It would be great to have this in the docs somewhere??

mrwwalmsley avatar Jan 11 '24 21:01 mrwwalmsley

Thank you to the comments above; this saved me a lot of time! I encountered the same thing while moving a test from jest to vitest.

Here's my version of the workaround, with business logic removed, in case it helps some future human (or LLM?).

There aren't any act warnings, and my tests still fail when expected. (I recognize this is very similar to something that OP explicitly said was not working for them, so ymmv.)

describe('myTestSubject', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  // seems like there's a bug in the interaction between userEvent / vi.useFakeTimers
  // see: https://github.com/testing-library/user-event/issues/1115
  const getUserEventInstance = () =>
    userEvent.setup({
      advanceTimers: vi.advanceTimersByTime.bind(vi),
    });

  it(`has some timer-based user interaction`, async () => {
    const { getBySomething } = render(<TestComponent />);

    const user = getUserEventInstance();
    await act(() => user.click(getBySomething(something)));
    await act(() => user.keyboard('a'));

    // (assert)
    vi.advanceTimersByTime(t);
    // (assert)
  });
});

majidmade avatar Oct 03 '24 22:10 majidmade

Hey there. I ran into this issue when migrating Jest to Vitest. After trying out different solutions, the easiest one was adding this line to the setup files:

vi.stubGlobal('jest', { advanceTimersByTime: vi.advanceTimersByTime.bind(vi) });

combined with:

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

although the only actual change was the first one, since the other one was needed to make Jest work.

Hope this help.

Happy coding.

felixbores avatar Oct 04 '24 04:10 felixbores

userEvent

Using delay: null is not recommended: https://testing-library.com/docs/user-event/options/#advancetimers

lukasalvarezdev avatar Oct 11 '24 11:10 lukasalvarezdev

Building on @iulspop solution, this alternative also avoids the TypeScript warning:

Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. ts(7017)

It also incorporates additional recommendations from the Vitest and React Testing Library's userEvent documentations:

beforeAll(() => {
  // https://vitest.dev/api/vi.html#vi-stubglobal
  vi.stubGlobal("jest", {
    advanceTimersByTime: vi.advanceTimersByTime.bind(vi),
  });
});

beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  // Ensures all pending timers are flushed before switching to real timers
  // Reference: https://testing-library.com/docs/using-fake-timers/
  vi.runOnlyPendingTimers();
  vi.useRealTimers();
});

afterAll(() => {
  vi.unstubAllGlobals();
});

/* ... */

test("example test", async () => {
  const user = userEvent.setup({
    advanceTimers: vi.advanceTimersByTime.bind(vi),
  });

  /* Test logic here */
});

amanape avatar Nov 24 '24 08:11 amanape

If you're using vitest with browser mode, you can switch to use vitest's userEvent implementation as described in the Interactivity API

That doesn't require any stubGlobal and just work out-of-the-box.

jonakyd avatar Aug 15 '25 03:08 jonakyd

I can confirm that userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) }) works after stubbing the global jest object, and does not work without the stubbing; but this looks like black magic.

The Options interface for userEvent admits any function in the advanceTimers option; and the docs just say:

When using fake timers it is necessary to set this option to your test runner's time advancement function. For example:
const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime})

Could anybody explain where exactly the tight coupling between userEvent and jest occurs, and why { advanceTimers: vi.advanceTimersByTime } is not sufficient on its own?

azangru avatar Oct 02 '25 09:10 azangru