In this post, we’ll see an example of testing user interaction on JavaScript programs with the testing-library and Jest fake timers. For a more detailed introduction of Jest and some testing tips, you can see my previous post.
Testing user interaction with the testing-library
User interactions, like having the user click on a button, are complex events that are hard to replicate in the testing environment. While the fireEvent
API, can be used to issue DOM events, it’s NOT the recommended method for testing user interaction as it doesn’t reflect how the user really interacts with the DOM. The right approach is to use the userEvent
API, which replicates user interaction with more fidelity.
Example component with delayed effects
For this simple demo, we’ll work with the following component.
import React, { useState } from "react"; const Demo = () => { const [isDisplayed, setIsDisplayed] = useState(true); const toggleHandler = () => setTimeout(()=>setIsDisplayed(prev => !prev), 500) return ( <React.Fragment> <div> {isDisplayed && <p>Hello World!</p>} </div> <button onClick={toggleHandler}>toggle</button> </React.Fragment> ); } export default Demo;
It consists of a simple text that is hidden or displayed after pressing the “toggle” button. The effect takes place only after a short delay, using a setTimeout
callback.
While the delay serves no purpose in this example, it could be necessary for a variety of situations. For example, pressing the button could trigger a fade animation before completely removing the text. Showing the text again could be done with an animation as well, like on this snackbar example. For simplicity, we will not add any of those effects.
Next, let’s test this component.
Testing user interaction
We would like to verify the text disappears after first pressing the button.
import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import Demo from "./Demo"; test("Pressing the button hides the text", async () => { const user = userEvent.setup(); render(<Demo />); const button = screen.getByRole("button"); await user.click(button); const text = screen.getByText("Hello World!"); await waitFor(() => expect(text).not.toBeInTheDocument()); });
The setup
method of userEvent
is part of user-event@14.0.0-beta
, which is the recommended approach at the moment of this writing.
Launching this test will succeed.
PASS src/Demo.test.jsx (6.377 s) ✓ Pressing the button hides the text (624 ms) Test Suites: 1 passed, 1 total
However, this test takes more than half a second (624 ms
) to complete. Here comes the need for fake timers.
Using fake timers to speed up user interaction tests
We’ll slightly modify our test to use Jest fake timers. They will allow us to manipulate the setTimeout
callbacks to be run immediately after pressing the button.
test("Pressing the button hides the text (fake timers)", async () => { const user = userEvent.setup(); jest.useFakeTimers(); render(<Demo />); const button = screen.getByRole("button"); await user.click(button); act(() => { jest.runAllTimers(); }); const text = screen.queryByText("Hello World!"); expect(text).not.toBeInTheDocument(); jest.useRealTimers(); });
See that we changed getByText
to queryByText
. getBy...
query methods fail when there is no matching element. queryBy...
methods don’t throw an error when no element is found.
Additionally, we add instructions to active and de-active the fake timers,jest.useFakeTimers
and jest.useRealTimers
, respectively. jest.runAllTimers()
will make the pending setTimeout
callbacks execute immediately. This way, we won’t have to wait for the setTimeout delay to complete during testing.
Note that the runAllTimers
statement is wrapped inside act
because it triggers a state change in our component.
Unfortunately, this test will fail.
FAIL src/Demo.test.jsx (10.984 s) ✕ Pressing the button hides the text (fake timers) (5010 ms) ● Pressing the button hides the text (fake timers) 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." ... Test Suites: 1 failed, 1 total
For some reason, using Jest fake timers doesn’t allow the user-event
methods to complete. The test fails due to timeout (which is set to a maximum of 5 seconds by default).
Let’s see how to solve this issue.
Solving timeout errors on user event tests with fake timers
The reason our previous test failed has to do with @testing-library/user-event
current implementation. By default, this library waits for a setTimeout
delay during its execution. Since jest.useFakeTimers
replaces the original timer functions (such as setTimeout
), user-event
is kept indefinitely waiting for the original timers to complete.
Fortunately, the solution is quite simple. We just need to set the delay
option to null
so that user-event
does not wait on setTimeout
.
const user = userEvent.setup({ delay: null });
Besides this single change, our test remains unchanged.
test("Pressing the button hides the text (fake timers)", async () => { const user = userEvent.setup({ delay: null }); jest.useFakeTimers(); render(<Demo />); const button = screen.getByRole("button"); await user.click(button); act(() => { jest.runAllTimers(); }); const text = screen.queryByText("Hello World!"); expect(text).not.toBeInTheDocument(); jest.useRealTimers(); });
Running the test again will pass with no errors.
PASS src/Demo.test.jsx (5.032 s) ✓ Pressing the button hides the text (fake timers) (118 ms) Test Suites: 1 passed, 1 total
We can see that the test is executed in about 100 ms, which shows that we’re effectively skipping the delay.
Thank you very much!
You are welcome!
I lost all hope with that. 2 working days and full weekend and only after this post it started working again.
I’m glad it solved your issue!
It really helped me, thank you!! 🙂
Thanks, this was very helpful and put me on the right track.
As per https://github.com/testing-library/user-event/issues/833#issuecomment-1171452841 a cleaner solution (preserving delay) might be:
thanks a lot!