I was writing some tests for a React debounced input component with the help of ChatGPT.
It helps me cut down on writing boilerplate and making typos, and since I’ve written hundreds of these tests manually I can confidently correct and modify the generated test code.
I was recently surprised to see some subtle differences from what I normally write with React Testing Library.
Intrigued, I fixed the bot’s code and discovered a 10 - 100x improvement in the speed of the tests. The difference? Using jest.useFakeTimers()
instead of await waitFor()
:
Using fake timers with jest.useFakeTimers()
is particularly useful when you want precise control over time-dependent code, such as testing debounced functions or animations. It allows you to:
- Avoid waiting: You can skip over time delays without waiting in real-time, making tests faster and more efficient.
- Control time progression: You can advance time precisely when needed, ensuring that time-based logic is tested accurately.
- Ensure consistency: Fake timers provide a consistent environment, avoiding issues with varying execution times in different test runs.
However, using async
/await
and waitFor
with @testing-library/react
and user-event
is another approach that can work well, especially for simulating real user interactions in a more natural flow. This method involves:
- Natural flow: Mimicking the actual sequence of user interactions and responses, providing more realistic test scenarios.
- Promise-based API: Using
async
/await
with promises allows you to wait for certain events or state changes naturally, making the code easier to read and maintain.
Control Flow of waitFor
- Setup and Configuration:
waitFor
is called with a callback function that defines the expected condition, and optionally, an options object.- The options object can include:some text
timeout
: Maximum time to wait for the condition to be met (default is 1000 ms).interval
: Time between checks for the condition (default is 50 ms).onTimeout
: A custom error message or action if the condition is not met within the timeout.
- Polling Mechanism:
waitFor
uses a polling mechanism to repeatedly execute the provided callback function at intervals specified by theinterval
option.- It will keep calling the callback until:
- The callback doesn't throw an error, indicating the condition has been met.
- The timeout is reached without the callback succeeding.
- Callback Execution:
- On each poll,
waitFor
executes the callback. - If the callback function throws an error (for example, because a queried element is not found or a condition is not met),
waitFor
catches the error and continues polling after the specified interval.
- On each poll,
- Success Path:
- If the callback completes without throwing an error (meaning the expected condition is met),
waitFor
resolves successfully, and the polling stops.
- If the callback completes without throwing an error (meaning the expected condition is met),
- Failure Path:
- If the timeout is reached without the condition being met,
waitFor
will throw an error, and this error will include:some text- The Last Error Message: The error message from the last execution of the callback that resulted in an error.
- The Stack Trace: The stack trace associated with the last error, which helps in identifying where the error occurred in the code.
- If the timeout is reached without the condition being met,
Now that we know the basics of why you'd want fake timers and how waitFor
works, let's dig into an example with tests written both ways.
Example System Under Test: DebouncedInput Component
// DebouncedInput.tsx
import { useEffect, useState } from "react";
import { useDebounceCallback } from "usehooks-ts";
type DebouncedInputProps = {
onChange: (value: string) => void;
parentValue: string | undefined;
};
export const DebouncedInput = ({
onChange,
parentValue,
}: DebouncedInputProps) => {
const [inputValue, setInput] = useState(parentValue);
const debounced = useDebounceCallback(onChange, 300);
useEffect(() => {
// Prevent duplicate onChange callback when clearing "parentValue" programatically
if (inputValue || parentValue) {
debounced(inputValue || "");
}
}, [inputValue]);
// This lets the parent component control the input value (e.g. clearing)
useEffect(() => {
if (parentValue !== inputValue) {
setInput(parentValue || "");
}
}, [parentValue]);
return (
<input
aria-label="DebouncedInput"
onChange={(event) => {
event.preventDefault();
setInput(event.target.value);
}}
value={inputValue}
/>
);
};
Test using waitFor
// DebouncedInputRTL.test.tsx
import { DebouncedInput } from "./DebouncedInput";
import { render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
describe("DebouncedInput", () => {
const setupUser = () => userEvent.setup();
it.only("calls onChange when a user types in the field", async () => {
const user = setupUser();
const onChange = jest.fn();
render(<DebouncedInput parentValue="" onChange={onChange} />);
const input = screen.getByLabelText<HTMLInputElement>("DebouncedInput");
await user.type(input, "Hello");
await waitFor(() => {
expect(input).toHaveValue("Hello");
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("Hello");
});
});
it.only("calls onChange with an empty string when a user erases the contents of the input", async () => {
const user = setupUser();
const onChange = jest.fn();
const value = "Initial Value";
render(<DebouncedInput parentValue={value} onChange={onChange} />);
const input = screen.getByLabelText<HTMLInputElement>("DebouncedInput");
await user.clear(input);
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("");
});
});
});
Test using jest.useFakeTimers()
// DebouncedInput.test.tsx
import { DebouncedInput } from "./DebouncedInput";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { act } from "react";
jest.useFakeTimers();
describe("DebouncedInput", () => {
afterEach(() => {
jest.clearAllTimers();
});
const setupUser = () =>
userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
it("calls onChange when a user types in the field", async () => {
const user = setupUser();
const onChange = jest.fn();
render(<DebouncedInput parentValue="" onChange={onChange} />);
const input = screen.getByLabelText<HTMLInputElement>("DebouncedInput");
await user.type(input, "Hello");
act(() => {
jest.advanceTimersByTime(300);
});
expect(input).toHaveValue("Hello");
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("Hello");
});
it("calls onChange with an empty string when a user erases the contents of the input", async () => {
const user = setupUser();
const onChange = jest.fn();
const value = "Initial Value";
render(<DebouncedInput parentValue={value} onChange={onChange} />);
const input = screen.getByLabelText<HTMLInputElement>("DebouncedInput");
await user.clear(input);
act(() => {
jest.advanceTimersByTime(300);
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("");
});
});
Key Points
With user-event v14, you can specify options to control the behavior of the testing utilities, including handling fake timers. When using fake timers, user-event adds delays to simulate real user interaction timing. To ensure that these delays work correctly with Jest's fake timers, you can use the advanceTimers
option during the setup.
userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
:- This setup function configures user-event to work with Jest's fake timers. The
advanceTimers
option is set tojest.advanceTimersByTime
, which advances the timer by the specified amount of time. - https://testing-library.com/docs/user-event/options#advancetimers
- This setup function configures user-event to work with Jest's fake timers. The
jest.useFakeTimers()
:- Enables Jest's fake timer mechanism. This allows you to control and manipulate time in your tests.
act(() => { jest.advanceTimersByTime(300); })
:- This code block advances the timers by 300 ms within the act wrapper. act ensures that all React updates related to this time advancement are processed.
Using await with user-event actions
:- Since user-event actions can return promises (especially when handling events that are async in nature, like typing with delays), we use await to ensure the actions are fully completed before making assertions.
This setup ensures that the debounced behavior of the component is tested accurately and efficiently, with the timer control provided by Jest's fake timers. It also aligns with the best practices for testing asynchronous and time-dependent behavior in React components.
Wrap up: When to Use Fake Timers vs. async
/await
- Use Fake Timers (
jest.useFakeTimers()
):- When testing functions with specific delays (like debounce or throttle).
- When you need to control the passage of time precisely.
- When you want to ensure that time-based logic is executed without actually waiting.
- Use
async
/await
:- When simulating real user interactions that involve waiting for UI updates or effects to settle.
- When testing asynchronous operations like API calls, where fake timers might not be applicable.