Jest单元测试防抖函数

32

我正在尝试为一个 防抖 函数编写单元测试。我很难思考。

这是代码:

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

我应该如何开始?


我在考虑为测试模拟一个CallBack函数,但我想要模拟并检查该函数是否根据传递给去抖动器的等待参数被调用。我走对了吗? - RecipeCreator
在这个上下文中,“防抖函数”是什么?它有什么用途? - Peter Mortensen
“节流”和“防抖”是优化事件处理的两种方式。它们是控制处理程序对事件响应速率的两种最常见的方法。 (JavaScript)。这是上下文吗? - Peter Mortensen
你本可以查看lodash debounce测试源代码 - vsync
8个回答

35

实际上,你不需要使用Sinon来测试防抖函数。Jest可以模拟JavaScript代码中的所有计时器。

请查看以下代码(它是TypeScript,但是你可以轻松将其翻译为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);
    });
});

更多信息:计时器模拟


3
这个方法很有效,但如果您没有使用Jest v27并遇到无限递归错误,请参见:https://dev59.com/uFQJ5IYBdhLWcg3w-K-J#64336022。 - mad.meesh
2
jest.useFakeTimers("modern") const foo = jest.fn() test("timer", () => { setTimeout(() => foo(), 2000) jest.runAllTimers() expect(foo).toBeCalledTimes(1) }) 你也可以像这样进行简单测试,不要忘记 jest.useFakeTimers() 的参数,它是可选的但可能会有很大区别。 - Marcus Ekström

27

如果你的代码中这样写:

import debounce from 'lodash/debounce';

myFunc = debounce(myFunc, 300);

如果你想要测试函数myFunc或者调用它的函数,那么在你的测试中,你可以使用jest模拟实现debounce,使其只返回你的函数:

```javascript jest.mock('./utils', () => ({ debounce: (fn) => fn, })); ```
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
    // ...
});

来源:https://gist.github.com/apieceofbart/d28690d52c46848c39d904ce8968bb27


1
模拟 lodash 的防抖似乎是个不错的选择。 - neaumusic
2
我使用的方法是: jest.mock('lodash/debounce', () => jest.fn(fn => fn)); - ElectroBuddha

21

您可能需要检查去抖动函数中的逻辑:

话虽如此,您似乎真正关心的问题是如何测试去抖动函数。

测试去抖动函数

您可以使用模拟跟踪函数调用和使用虚拟计时器模拟时间流逝来测试函数是否已经去抖动。

以下是一个简单的示例,使用Jest Mock FunctionSinon 虚拟计时器 来测试使用 Lodashdebounce() 进行去抖动的函数:

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

1
@RecipeCreator 欢迎来到SO!由于您是新手,友情提醒:如果答案提供了您需要的信息,请标记为已完成并点赞(当您获得该能力时)。 - Brian Adams
1
有没有一种方法可以在不使用 sinon 的情况下完成它?使用 Jest Timers Mocks(https://jestjs.io/docs/en/timer-mocks)? - latata
@BrianAdams 很棒的解决方案!非常易于理解。 - jrnxf

5

使用现代的虚拟定时器(默认已经在 Jest 27 中)可以更加简洁地测试它:

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

您可以实现一个状态钩子,像这样进行防抖处理...
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];
}

测试如下

/**
 * @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");
    });

  });
});

源代码可在https://github.com/trajano/react-hooks-tests/tree/master/src/useDebouncedState找到。


4

我喜欢这个更简单易懂的类似版本,可以更容易地失败:

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

1
这个很好用,但如果你没有使用jest v27并遇到无限递归错误,请参考:https://dev59.com/uFQJ5IYBdhLWcg3w-K-J#64336022 - mad.meesh
“更容易失败”是什么意思?你能详细说明一下吗? - Peter Mortensen
我是说更容易测试返回假值的情况。在这种情况下,如果我们将jest.advanceTimersByTime()设置为600,单元测试将失败,这会让我们确信防抖函数是正确的,因为它将被调用两次。 - Nicc

0
花了很多时间才弄明白...最后终于成功了...
jest.mock('lodash', () => {
    const module = jest.requireActual('lodash');
    module.debounce = jest.fn(fn => fn);
    return module;
});

0

以下是我的 3 个基本测试:

它们是测试「去抖动」逻辑的基础。
请注意,由于被测试的本身就是异步的,因此所有测试都是 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) 
})

这些测试是由我编写的,而不是从 lodash debounce 测试源代码 中获取的。


1
我认为你的wait()函数应该被称为delay以匹配代码的其余部分。 - Roy Shilkrot

-1
另一种方法是刷新去抖函数,让它立即执行:
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);
});

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接