Jest unit test for a debounce function

Viewed 41965

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?

8 Answers

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

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

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
});

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);
});

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

Here's my 3 basic tests:

There are fundemental for testing debounce logic.
Note that all tests are async since the nature of what is tested is by itself async.

import debounce from 'lodash.debounce'

const wait = ms => new Promise(resolve => setTimeout(resolve, ms))

test('called repeatedly', async () => {
  const DELAY = 100;
  let callCount = 0;
  const debounced = debounce(() => ++callCount, DELAY)

  for( let i = 4; i--; )
    debounced()
 
  await delay(DELAY)
  expect( callCount ).toBe(1) 
})


test('called repeatedly exactly after the delay', async () => {
  const DELAY = 100;
  let callCount = 0, times = 3;
  const debounced = debounce(() => ++callCount, DELAY)

  for( let i = times; i--; ) {
    debounced()
    await delay(DELAY) 
  }
 
  await delay(DELAY * times)
  expect( callCount ).toBe(3) 
})


test('called repeatedly at an interval small than the delay', async () => {
  const DELAY = 100;
  let callCount = 0, times = 6;
  const debounced = debounce(() => ++callCount, DELAY)

  for( let i = times; i--; ) {
    debounced()
    await delay(DELAY/2) 
  }
 
  await delay(DELAY * times)
  expect( callCount ).toBe(1) 
})

These tests were written by me and were not taken from lodash debounce tests source code

Another way would be to flush the debounce function to let it execute immediately:

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

    // Execute for the first time
    debouncedFunc();
    debouncedFunc.flush();

  
    // try to execute a 2nd time
    debouncedFunc();
    debouncedFunc.flush();

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

spent lot of time to figure out ...finally this worked..

jest.mock('lodash', () => {
    const module = jest.requireActual('lodash');
    module.debounce = jest.fn(fn => fn);
    return module;
});
Related