Jest unit test for a debounce function
Solution 1
You will probably want to check the logic in your debouncer function:
-
timeout
will always be set by that lastif()
statement -
this
will always beundefined
since arrow functions use "thethis
value of the enclosing lexical context" anddebouncer()
is designed to be used as a stand-alone 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
Related videos on Youtube
RecipeCreator
Updated on July 09, 2022Comments
-
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?
-
RecipeCreator over 5 yearsI 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?
-
Peter Mortensen over 3 yearsWhat is a "debounce function" in this context? What is it used for?
-
Peter Mortensen over 3 years
-
vsync about 2 yearsYou could have just looked at lodash debounce tests source code
-
-
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 almost 5 yearsis there a way to make it done without sinon? using Jest Timers Mocks (jestjs.io/docs/en/timer-mocks)?
-
thatvegandev about 4 years@BrianAdams great solution! Very easy to understand.
-
mad.meesh over 3 yearsthis 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 over 3 yearsthis 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 over 3 yearsWhat do you mean by "easier to have failing"? Can you elaborate?
-
Nicc over 3 yearsi 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 about 3 yearsmocking lodash debounce seems like the move
-
Marcus Ekström about 3 yearsjest.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 almost 3 yearsWhat worked for me was:
jest.mock('lodash/debounce', () => jest.fn(fn => fn));