Jest unit test for a debounce function

36,690

Solution 1

You will probably want to check the logic in your debouncer function:

Having said that, it sounds like your real question is about testing debounced functions.

Testing debounced functions

You can test that a function is debounced by using a mock to track function calls and fake timers to simulate the passage of time.

Here is a simple example using a Jest Mock Function and Sinon fake timers of a function debounced using debounce() from Lodash:

const _ = require('lodash');
import * as sinon from 'sinon';

let clock;

beforeEach(() => {
  clock = sinon.useFakeTimers();
});

afterEach(() => {
  clock.restore();
});

test('debounce', () => {
  const func = jest.fn();
  const debouncedFunc = _.debounce(func, 1000);

  // Call it immediately
  debouncedFunc();
  expect(func).toHaveBeenCalledTimes(0); // func not called

  // Call it several times with 500ms between each call
  for(let i = 0; i < 10; i++) {
    clock.tick(500);
    debouncedFunc();
  }
  expect(func).toHaveBeenCalledTimes(0); // func not called

  // wait 1000ms
  clock.tick(1000);
  expect(func).toHaveBeenCalledTimes(1);  // func called
});

Solution 2

Actually, you don't need to use Sinon to test debounces. Jest can mock all timers in JavaScript code.

Check out following code (it's TypeScript, but you can easily translate it to JavaScript):

import * as _ from 'lodash';

// Tell Jest to mock all timeout functions
jest.useFakeTimers();

describe('debounce', () => {

    let func: jest.Mock;
    let debouncedFunc: Function;

    beforeEach(() => {
        func = jest.fn();
        debouncedFunc = _.debounce(func, 1000);
    });

    test('execute just once', () => {
        for (let i = 0; i < 100; i++) {
            debouncedFunc();
        }

        // Fast-forward time
        jest.runAllTimers();

        expect(func).toBeCalledTimes(1);
    });
});

More information: Timer Mocks

Solution 3

If in your code you are doing so:

import debounce from 'lodash/debounce';

myFunc = debounce(myFunc, 300);

and you want to test the function myFunc or a function calling it, then in your test you can mock the implementation of debounce using jest to make it just return your function:

import debounce from 'lodash/debounce';

// Tell Jest to mock this import
jest.mock('lodash/debounce');

it('my test', () => {
    // ...
    debounce.mockImplementation(fn => fn); // Assign the import a new implementation. In this case it's to execute the function given to you
    // ...
});

Source: https://gist.github.com/apieceofbart/d28690d52c46848c39d904ce8968bb27

Solution 4

I like this similar version easier to have failing:

jest.useFakeTimers();
test('execute just once', () => {
    const func = jest.fn();
    const debouncedFunc = debounce(func, 500);

    // Execute for the first time
    debouncedFunc();

    // Move on the timer
    jest.advanceTimersByTime(250);
    // try to execute a 2nd time
    debouncedFunc();

    // Fast-forward time
    jest.runAllTimers();

    expect(func).toBeCalledTimes(1);
});

Solution 5

Using the modern fake timers (default already by Jest 27) you can test it more concisely:

import debounce from "lodash.debounce";
describe("debounce", () => {
  beforeEach(() => {
    jest.useFakeTimers("modern");
  });
  afterEach(() => {
    jest.useRealTimers();
  });
  it("should work properly", () => {
    const callback = jest.fn();
    const debounced = debounce(callback, 500);
    debounced();
    expect(callback).not.toBeCalled();

    jest.advanceTimersByTime(100);
    debounced();
    expect(callback).not.toBeCalled();

    jest.advanceTimersByTime(499);
    expect(callback).not.toBeCalled();

    jest.advanceTimersByTime(1);
    expect(callback).toBeCalledTimes(1);
  });

  it("should fire with lead", () => {
    const callback = jest.fn();
    const debounced = debounce(callback, 500, { leading: true });
    expect(callback).not.toBeCalled();
    debounced();
    expect(callback).toBeCalledTimes(1);

    jest.advanceTimersByTime(100);
    debounced();
    expect(callback).toBeCalledTimes(1);

    jest.advanceTimersByTime(499);
    expect(callback).toBeCalledTimes(1);

    jest.advanceTimersByTime(1);
    expect(callback).toBeCalledTimes(2);
  });
});

You can implement this as a state hook that's debounced like this...

import debounce from "lodash.debounce";
import { Dispatch, useCallback, useState } from "react";

export function useDebouncedState<S>(
  initialValue: S,
  wait: number,
  debounceSettings?: Parameters<typeof debounce>[2]
): [S, Dispatch<S>] {
  const [state, setState] = useState<S>(initialValue);
  const debouncedSetState = useCallback(
    debounce(setState, wait, debounceSettings),
    [wait, debounceSettings]
  );
  return [state, debouncedSetState];
}

And test as

/**
 * @jest-environment jsdom
 */
import { act, render, waitFor } from '@testing-library/react';
import React from 'react';
import { useDebouncedState } from "./useDebouncedState";

describe("useDebounceState", () => {
  beforeEach(() => {
    jest.useFakeTimers("modern");
  });
  afterEach(() => {
    jest.useRealTimers();
  });
  it("should work properly", async () => {
    const callback = jest.fn();
    let clickCount = 0;
    function MyComponent() {
      const [foo, setFoo] = useDebouncedState("bar", 500);
      callback();
      return <div data-testid="elem" onClick={() => { ++clickCount; setFoo("click " + clickCount); }}>{foo}</div>
    }
    const { getByTestId } = render(<MyComponent />)
    const elem = getByTestId("elem");

    expect(callback).toBeCalledTimes(1);
    expect(elem.textContent).toEqual("bar");

    jest.advanceTimersByTime(100);
    elem.click();
    expect(callback).toBeCalledTimes(1);
    expect(elem.textContent).toEqual("bar");

    jest.advanceTimersByTime(399);
    expect(callback).toBeCalledTimes(1);
    expect(elem.textContent).toEqual("bar");

    act(() => jest.advanceTimersByTime(1));

    await waitFor(() => {
      expect(callback).toBeCalledTimes(2);
      expect(elem.textContent).toEqual("click 1");
    });

    elem.click();
    await waitFor(() => {
      expect(callback).toBeCalledTimes(2);
      expect(elem.textContent).toEqual("click 1");
    });
    act(() => jest.advanceTimersByTime(500));
    await waitFor(() => {
      expect(callback).toBeCalledTimes(3);
      expect(elem.textContent).toEqual("click 2");
    });

  });
});

Source code available at https://github.com/trajano/react-hooks-tests/tree/master/src/useDebouncedState

Share:
36,690

Related videos on Youtube

RecipeCreator
Author by

RecipeCreator

Updated on July 09, 2022

Comments

  • RecipeCreator
    RecipeCreator almost 2 years

    I am trying to write a unit test for a debounce function. I'm having a hard time thinking about it.

    This is the code:

    function debouncer(func, wait, immediate) {
      let timeout;
    
      return (...args) => {
        clearTimeout(timeout);
    
        timeout = setTimeout(() => {
          timeout = null;
          if (!immediate) 
            func.apply(this, args);
        }, wait);
    
        if (immediate && !timeout) 
          func.apply(this, args);
      };
    }
    

    How should I start?

  • Brian Adams
    Brian Adams over 5 years
    @RecipeCreator welcome to SO! Since you are new, friendly reminder to mark as completed and upvote (when you gain that ability) if an answer provides the info you need
  • latata
    latata almost 5 years
    is there a way to make it done without sinon? using Jest Timers Mocks (jestjs.io/docs/en/timer-mocks)?
  • thatvegandev
    thatvegandev about 4 years
    @BrianAdams great solution! Very easy to understand.
  • mad.meesh
    mad.meesh over 3 years
    this worked great but if you're not using jest v27 and run into an infinite recursion error refer to: stackoverflow.com/a/64336022/4844024
  • mad.meesh
    mad.meesh over 3 years
    this worked great but if you're not using jest v27 and run into an infinite recursion error see: stackoverflow.com/a/64336022/4844024
  • Peter Mortensen
    Peter Mortensen over 3 years
    What do you mean by "easier to have failing"? Can you elaborate?
  • Nicc
    Nicc over 3 years
    i meant easier to test a scenario which returns a falsy result. in this case if we set the jest.advanceTimersByTime() to 600, the unit-test will fail which confort us in that the debounce function does the right thing since it will be called twice.
  • neaumusic
    neaumusic about 3 years
    mocking lodash debounce seems like the move
  • Marcus Ekström
    Marcus Ekström about 3 years
    jest.useFakeTimers("modern") const foo = jest.fn() test("timer", () => { setTimeout(() => foo(), 2000) jest.runAllTimers() expect(foo).toBeCalledTimes(1) }) You can also just make a easier test like this, and don't forget the param to jest.useFakeTimers(), it's optional but can make all the difference.
  • ElectroBuddha
    ElectroBuddha almost 3 years
    What worked for me was: jest.mock('lodash/debounce', () => jest.fn(fn => fn));