Skip to main content
Test Double company logo
Services
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say Hello
Test Double logo
Menu
Services
BackGrid of dots icon
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
Cycle icon
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say hello
Developers
Developers
Developers
Software tooling & tips

Faster tests with Jest timers vs. waitFor on debounced inputs

Using Jest's fake timers can drastically improve the speed of your tests for debounced inputs. See a side-by-side comparison with waitFor.
James Walker
|
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

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:

  1. Avoid waiting: You can skip over time delays without waiting in real-time, making tests faster and more efficient.
  2. Control time progression: You can advance time precisely when needed, ensuring that time-based logic is tested accurately.
  3. 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:

  1. Natural flow: Mimicking the actual sequence of user interactions and responses, providing more realistic test scenarios.
  2. 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

  1. 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.
  2. Polling Mechanism:
    • waitFor uses a polling mechanism to repeatedly execute the provided callback function at intervals specified by the interval 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.
  3. 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.
  4. Success Path:
    • If the callback completes without throwing an error (meaning the expected condition is met), waitFor resolves successfully, and the polling stops.
  5. 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.

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.

  1. userEvent.setup({ advanceTimers: jest.advanceTimersByTime }):
    • This setup function configures user-event to work with Jest's fake timers. The advanceTimers option is set to jest.advanceTimersByTime, which advances the timer by the specified amount of time.
    • https://testing-library.com/docs/user-event/options#advancetimers
  2. jest.useFakeTimers():
    • Enables Jest's fake timer mechanism. This allows you to control and manipulate time in your tests.
  3. 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.
  4. 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.

‍

Your monthly dose of better dev

Join the Test Double Dispatch for hot takes, industry trends, tooling tips, and tricks to level up your coding—delivered right to you.

Let's do this

Related Insights

🔗
Effective React testing
🔗
The nine best recommendations in the new React docs

Explore our insights

See all insights
Leadership
Leadership
Leadership
Why we coach the system, not just the team

Slow delivery isn’t usually about your people—it’s about your system. Shifting focus to incremental improvements in the system helps change not just processes but behaviors for lasting change.

by
Doc Norton
Developers
Developers
Developers
Developer QA checklist for feature releases

Quality Assurance is a mindset integrated throughout development to catch issues early, build user trust, and reduce maintenance costs. These recommended procedures for dev teams without dedicated QA roles establish collective responsibility for ensuring feature stability, functionality, and usability before release.

by
Lee Quarella
Developers
Developers
Developers
From engineer to consultant: The powerful shift from inward to outward focus

What transforms a skilled software engineer into an exceptional consultant? Approach new codebases with respect rather than judgment, embrace constraints as creative boundaries, and prioritize client needs over personal preferences.

by
Dave Mosher
Letter art spelling out NEAT

Join the conversation

Technology is a means to an end: answers to very human questions. That’s why we created a community for developers and product managers.

Explore the community
Test Double Executive Leadership Team

Learn about our team

Like what we have to say about building great software and great teams?

Get to know us
No items found.
Test Double company logo
Improving the way the world builds software.
What we do
Services OverviewSoftware DeliveryProduct ManagementLegacy ModernizationDevOpsUpgrade RailsTechnical RecruitmentTechnical Assessments
Who WE ARE
About UsCulture & CareersGreat CausesEDIOur TeamContact UsNews & AwardsN.E.A.T.
Resources
Case StudiesAll InsightsLeadership InsightsDeveloper InsightsProduct InsightsPairing & Office Hours
NEWSLETTER
Sign up hear about our latest innovations.
Your email has been added!
Oops! Something went wrong while submitting the form.
Standard Ruby badge
614.349.4279hello@testdouble.com
Privacy Policy
© 2020 Test Double. All Rights Reserved.

Your monthly dose of better dev

Join the Test Double Dispatch for hot takes, industry trends, tooling tips, and tricks to level up your coding—delivered right to you.

Let's do this