Fake timers doesn't work with latest version of user-event?
Asked Answered
S

3

5

Following is a custom hook that I wish to test:

import { useEffect, useState } from "react";

export const useAlert = () => {
  const [alert, setAlert] = useState(null);

  useEffect(() => {
    let timerId = setTimeout(() => {
      console.log("Timeout, removing alert from DOM");
      setAlert(null);
    }, 200);

    return () => clearTimeout(timerId);
  }, [alert]);

  return {
    renderAlert: alert ? alert : null,
    setAlert,
  };
};

It simply allows a component to set alerts and clears it automatically after 300ms.

This is a working test for the above hook

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

// jest.useFakeTimers();

function FakeComponent() {
  const { renderAlert, setAlert } = useAlert();

  return (
    <div>
      <button onClick={() => setAlert("fake alert")}>Set Alert</button>
      <p>{renderAlert}</p>
    </div>
  );
}

test("testing", async () => {
  const user = userEvent.setup();
  render(<FakeComponent />);
  const button = screen.getByRole("button", { name: /set alert/i });

  await user.click(button);
  expect(screen.getByText(/fake alert/i)).toBeInTheDocument();

  await waitFor(() => {
    expect(screen.queryByText(/fake alert/i)).not.toBeInTheDocument();
  });
});

My doubt is that I want to use jest's fake timers inside the test but if I uncomment the line jest.useFakeTimers(), the test breaks saying that the test timed out as the default timeout value is 5000ms consider increasing the timeout if it's a long running test.

I don't understand why this happens, any help please!

Sik answered 17/4, 2022 at 10:48 Comment(0)
H
12

To use fake timers with @testing-library/user-event's latest version, you need to configure the the advanceTimers option (docs) while setting up userEvent:

const user = userEvent.setup({
  advanceTimers: () => jest.runOnlyPendingTimers(),
});

Here's your updated test:

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

jest.useFakeTimers();

function FakeComponent() {
  const { renderAlert, setAlert } = useAlert();

  return (
    <div>
      <button onClick={() => setAlert("fake alert")}>Set Alert</button>
      <p>{renderAlert}</p>
    </div>
  );
}

test("testing", async () => {
  const user = userEvent.setup({
    advanceTimers: () => jest.runOnlyPendingTimers(),
  });
  render(<FakeComponent />);
  const button = screen.getByRole("button", { name: /set alert/i });

  await user.click(button);
  expect(screen.getByText(/fake alert/i)).toBeInTheDocument();

  act(() => jest.runAllTimers());
  expect(screen.queryByText(/fake alert/i)).toBeNull();
});
Haematozoon answered 17/4, 2022 at 20:6 Comment(0)
A
2

You can pass the delay property as null to your userEvent.setup call


test("testing", async () => {
  const user = userEvent.setup({ delay: null });

  render(<FakeComponent />);
  const button = screen.getByRole("button", { name: /set alert/i });

  await user.click(button);
});
Automatic answered 19/7, 2022 at 14:43 Comment(0)
C
0

The solution by @som-shekhar-mukherjee is good, but not rigorous, although it manages to work for this very particular case.

I suggest, however, using advanceTimersToNextTimer instead of runOnlyPendingTimers on the advanceTimers config of userEvent.

Doing so will ensure jest only runs the timers set by userEvent's internal methods. If the action userEvent is triggering something on your code that uses timers, runOnlyPendingTimers will exhaust those. advanceTimersToNextTimer will only advance the ones caused by userEvent.

Basically, your setTimeout callback needs to run twice on your test.

  • Once: at least 200 ms after the component mounted (because useEffect will be called when the component is mounted).
  • Twice: at least 200 ms after "fake alert" is set.

The solution by @som-shekhar-mukherjee works because once your test reaches and executes the line await user.click(button);, the runOnlyPendingTimers will exhaust all pending timers:

  • the timer set by useEffect when the component mounted for the test;
  • the timers set by the user.click call.

It happens to work for your case, but prevents you from controlling when the timers you are testing happen. With that solution, the line await user.click(button); is implicitly (and you are probably unknowingly) advancing the timer of your component under test. Which is not correct, because, in the real execution it will not be exhausted only when the button is clicked. I found all these singularities because the tests for my codebase weren't as simple as this.

So I suggest the following:

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

afterEach(() => {
  jest.runOnlyPendingTimers();
  jest.useRealTimers();
});

test("testing", async () => {
  const user = userEvent.setup({
    advanceTimers: () => jest.advanceTimersToNextTimer(),
  });

  render(<FakeComponent />);

  // Basically allows the callback scheduled via the component mount to run.
  // You could control more granularly what timers you want to advance here.
  jest.runOnlyPendingTimers();

  const button = screen.getByRole("button", { name: /set alert/i });

  // `advanceTimersToNextTimer` will prevent this from blocking indefinitely
  // and that's what, primarily, solves your question.
  await user.click(button);

  expect(screen.getByText(/fake alert/i)).toBeInTheDocument();

  // Allows the callback scheduled via the set of "fake alert" to run.
  // Again, you could control more granularly what timers you want to advance here.
  act(() => jest.runOnlyPendingTimers());

  expect(screen.queryByText(/fake alert/i)).not.toBeInTheDocument();
});

To sum it up, using advanceTimersToNextTimer on userEvent config solves the blocking issue and allows you to more correctly control what is happening with the timers of the tested component during your test. The test hooks are suggested by React Testing Library docs. Btw, I tested my proposed solution carefully with your component and hook above. Let me know if you have questions about my answer.

Clawson answered 19/12, 2023 at 19:5 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.