31

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?

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
RecipeCreator
  • 323
  • 1
  • 5
  • 10
  • I was thinking about mocking a CallBack function for testing, but I would to mock and check it that function get called based on wait parameter passed to debouncer. Am on write track? – RecipeCreator Sep 07 '18 at 14:59
  • What is a "debounce function" in this context? What is it used for? – Peter Mortensen Sep 08 '20 at 22:21
  • *"[Throttling and debouncing are two ways to optimize event handling. ... Throttling and debouncing are two most common ways to control a handler function response rate to an event.](https://redd.one/blog/debounce-vs-throttle/)"* (JavaScript). Is that the context? – Peter Mortensen Jan 04 '21 at 20:37
  • You could have just looked at [lodash *debounce* tests source code](https://github.com/lodash/lodash/blob/master/test/debounce.test.js) – vsync Mar 19 '22 at 10:03

8 Answers8

35

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

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
tswistak
  • 487
  • 5
  • 9
  • 3
    this worked great but if you're not using jest v27 and run into an infinite recursion error see: https://stackoverflow.com/a/64336022/4844024 – mad.meesh Dec 16 '20 at 03:05
  • 2
    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. – Marcus Ekström Mar 03 '21 at 10:28
26

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

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Mohamed Ibrahim Elsayed
  • 2,734
  • 3
  • 23
  • 43
21

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
});
Brian Adams
  • 43,011
  • 9
  • 113
  • 111
  • 1
    @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 – Brian Adams Sep 07 '18 at 17:20
  • 1
    is there a way to make it done without sinon? using Jest Timers Mocks (https://jestjs.io/docs/en/timer-mocks)? – latata May 17 '19 at 20:37
  • @BrianAdams great solution! Very easy to understand. – jrnxf Apr 09 '20 at 16:41
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);
});
Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Nicc
  • 777
  • 3
  • 8
  • 19
  • 1
    this worked great but if you're not using jest v27 and run into an infinite recursion error refer to: https://stackoverflow.com/a/64336022/4844024 – mad.meesh Dec 16 '20 at 03:05
  • What do you mean by *"easier to have failing"*? Can you elaborate? – Peter Mortensen Jan 04 '21 at 20:44
  • 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. – Nicc Jan 08 '21 at 10:15
4

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

Archimedes Trajano
  • 35,625
  • 19
  • 175
  • 265
0

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

Here's my 3 basic tests:

There are fundamental 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 delay = 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

vsync
  • 118,978
  • 58
  • 307
  • 400
-1

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);
});
Cyberto
  • 9
  • 2