jest: calling runAllTimers after using Lodash's _.debounce results in an infinite recursion error

Do you want to request a feature or report a bug?

bug

What is the current behavior?

When using fake timers, creating a debounced function, calling it a couple times, and then calling jest.runAllTimers, an error will be printed:

Ran 100000 timers, and there are still more! Assuming we've hit an infinite recursion and bailing out...

  at FakeTimers.runAllTimers (node_modules/jest-util/build/FakeTimers.js:207:13)
  at Object.<anonymous> (__tests__/lodash-bug-test.js:12:8)

It seems that changing the second argument passed to debounce (the time in milliseconds to debounce the function for) changes whether or not this error occurs. For example: on my machine (mid-2014 MBP) it appears to always throw when the delay is above ~600ms, but only fails some of the time when it’s around 500ms.

This issue has been encountered before (https://github.com/lodash/lodash/issues/2893), and it seems to have been on Lodash’s end. I added a comment to the issue in the Lodash repo, but @jdalton said that he’s not sure why it would still be occurring with recent versions of Lodash.

If the current behavior is a bug, please provide the steps to reproduce and either a repl.it demo through https://repl.it/languages/jest or a minimal repository on GitHub that we can yarn install and yarn test.

https://github.com/rimunroe/lodash-jest-timer-issue

What is the expected behavior?

The calling jest.runAllTimers should cause the debounced function to behave as though the time it was told to debounce for elapsed.

Please provide your exact Jest configuration and mention your Jest, node, yarn/npm version and operating system.

I’m using macOS 10.12.4

No configuration other than calling jest.runAllTimers in the test.

I encountered the bug with the following versions:

  • node@4.4.7 with npm@2.15.8
  • node@6.10.3 with npm@4.2.0 and yarn@0.23.4
  • jest@18.1.0
  • jest@19.0.1
  • lodash@4.17.4

About this issue

  • Original URL
  • State: closed
  • Created 7 years ago
  • Reactions: 63
  • Comments: 41 (13 by maintainers)

Commits related to this issue

Most upvoted comments

I was able to get around this by mocking lodash’s debounce module

import debounce from 'lodash/debounce'
import { someFunction, someDebouncedFunction } from '../myModule';
jest.mock('lodash/debounce', () => jest.fn(fn => fn));

...

it('tests something', () => {
  debounce.mockClear();
  someFunction = jest.fn();

  someDebouncedFunction();

  expect(debounce).toHaveBeenCalledTimes(1);
  expect(someFunction).toHaveBeenCalledTimes(1);
});

Merged a fix 1 day before the issue’s 3 year anniversary 😅 Available in jest@26.0.0-alpha.1 via jest.useFakeTimers('modern'). next docs: https://jestjs.io/docs/en/next/jest-object#jestusefaketimersimplementation-modern--legacy

@rimunroe I had the same error when using jest.runAllTimers(). I switched to jest.runOnlyPendingTimers() and this fixed my problem.

jest.runOnlyPendingTimers() eliminates the error message for me, but the method is never invoked.

Managed to work around this with a combination of jest.useRealTimers(), setTimeout and done.

  it('debounces the prop', done => {
    wrapper.prop('debounced')('arg1', 'arg2');
    wrapper.prop('debounced')('arg3', 'arg4');
    wrapper.prop('debounced')('arg5', 'arg6');
    setTimeout(() => {
      expect(props.debounced.calls.count()).toBe(1);
      expect(props.debounced).toHaveBeenCalledWith('arg5', 'arg6');
      done();
    }, 1000);
  });

for those who don’t want to read the whole thread

  • Lodash’s throttle uses debounce inside, that uses Date.now(), so not only timers should be faked, but the Date API too.
  • That kind of stuff was done in lolex.
  • @SimenB made several PRs that complete jest fake timers with lolex timers.
  • The latest PR is waiting for reviews from @thymikee and @rubennorte.

Lodash throttles by way of debounce. It’s robust and handles things like clock drift after daylights savings time. That said, IMO it’s not really Lodash’s burden to prop up a mock library. We do our part to be good neighbors and don’t hold on to timer references like setTimeout. Beyond that it really depends on your level of mock. For example, you could mock the debounced function itself instead of the underlying timer apis.

I used this to mock lodash’s debounce, it works for most of my use cases: place the following in __mocks__/lodash/debounce/index.js in your root project directory

export const debounce = jest.fn().mockImplementation((callback, timeout) => {
  let timeoutId = null;
  const debounced = jest.fn(()=>{
    window.clearTimeout(timeoutId);
    timeoutId = window.setTimeout(callback, timeout);
  });

  const cancel = jest.fn(()=>{
    window.clearTimeout(timeoutId);
  });

  debounced.cancel = cancel;
  return debounced;
});

export default debounce;

then just use it with jest’s timer mocking and your tests should behave correctly. as always, extend as appropriate 😃

Btw, I hit this too. Turns out lodash’s implementation of throttle is way more complex than it (imo) should be. Ended up with a simple helper like this one because I didn’t have time to debug what’s wrong.

I still needed debounce behavior in my tests, so mocking debounce to return the function wouldn’t work. But I also didn’t need the level of robustness that _.debounce provides. Mocking lodash’s robust debounce with a naive debounce fit my needs. I put this in my project:

__mocks__/lodash/debounce.js

  let timer = null;
  return function wrappedFunction(...args) {
    const context = this;
    clearTimeout(timer);
    timer = setTimeout(function invokeFunction() {
      fn.apply(context, args);
    }, delay);
  };
}

I had a case where I wanted to test component which used _.debounce in several places and I had to mock implementation of only one usage, I’ve done it in following way:

const originalDebounce = _.debounce;
jest.spyOn(_, 'debounce').mockImplementation((f, delay) => {
    // can also check if f === yourDebouncedFunction
    if (delay === constants.debounceTime) {
        return f;
    }
    return originalDebounce(f, delay);
});

Hope this will help someone

Opened up #5165 for it.

I just mocked it so debounce returns the passed function like so:

jest.mock('lodash/debounce', () => fn => fn);

This worked in my particular case where I was calling the function directly in the test anyway but no good if you need to actually test that the debounce functionality itself works by calling it multiple times…

I believe I know what’s going on here.

To implement its voluminous functionality, throttle (which is essentially a wrapper around debounce) passes control between a series of setTimeout calls. These calls handle various circumstances when it could be time to invoke the wrapped function (leading edge, trailing edge, etc). One of the places setTimeout is used is here:

  function timerExpired() {
    var time = now();

    // Handle the case where we should invoke now
    if (shouldInvoke(time)) {
      return trailingEdge(time);
    }

    // Handle the case where we're not done yet.
    // Restart the timer.
    timerId = setTimeout(timerExpired, remainingWait(time));
  }

This function looks at the current time, and decides if it’s time to invoke the wrapped function. It assumes that the timeout function is actually occurring delay milliseconds in the future. In this case, that’s not true, because Jest does not mock the current time. Here’s an excerpt of what Jest does:

    for (i = 0; i < this._maxLoops; i++) {
      const nextTimerHandle = this._getNextTimerHandle();
      this._runTimerHandle(nextTimerHandle);
    }

This is a tight loop, which is why @tleunen (and I) observe delay values that are declining slowly (498ms remaining, 497ms, etc). And it explains why @rimunroe (and I) noticed that the “infinite timeouts” Jest error is only thrown above certain wait times. If it takes Jest X milliseconds to run through the timeout execution loop shown above 100k times, then throttle will work if the wait is less than X.

Lodash reads the current time from Date.now. Jest can fix this by mocking out Date, and making the timeout execution loop something like:

    for (i = 0; i < this._maxLoops; i++) {
      const nextTimerHandle = this._getNextTimerHandle();

      // Update mocked time
      Date.now += getExpiry(nextTimerHandle)

      this._runTimerHandle(nextTimerHandle);
    }

Got it to work with sinonjs fake timers. Here’s a small sample:

import FakeTimers from '@sinonjs/fake-timers';

let clock;

describe('some tests', () => {

  beforeEach(() => {
    clock = FakeTimers.createClock();
  });

  it('should do a thing', () => {
    const fakeWaitInMillis = 5000;

    // call func that uses debounce

    clock.setTimeout(() => {
      // expect func to be called after wait
    }, fakeWaitInMillis);
  });
  
});

Thanks @xevrem! But the debounced function is not being passed the correct arguments, I suggest updating your example with this (accept and spread args):

const debounced = jest.fn((...args) => {
  window.clearTimeout(timeoutId);
  timeoutId = window.setTimeout(() => callback(...args), timeout);
});

I am having this same issue. Using sinon’s fake timers I am able to advance the clock and test that a debounced function is called. I am trying to convert to Jest, and using Jest’s fake timers, I get Ran 100000 timers, and there are still more! when using jest.runAllTimers, and the function is not invoked when using jest.runOnlyPendingTimers or jest.runTimersToTime.

I am able to use real timers and do something similar to @jkaipr above:

it('triggers debounce', (done) => {
  //set up and call debounced function

  setTimeout(() => {
    //test function was called
    done()
  }, 300 /* however long my debounce is */)
})

little addition to @xevrem version here (adding args support):

export const debounce = jest.fn().mockImplementation((callback, timeout) => {
  let timeoutId = null;
  const debounced = jest.fn((...args)=>{
    window.clearTimeout(timeoutId);
    timeoutId = window.setTimeout(() => callback(...args), timeout);
  });

  const cancel = jest.fn(()=>{
    window.clearTimeout(timeoutId);
  });

  debounced.cancel = cancel;
  return debounced;
});

export default debounce;

Please open up a new issue with a minimal reproduction if you’re stilling having issues

Another basic mock similar to @xevrem answer but with a mocked .flush() as well, in case you need that.

Also note if you are just importing the lodash.debounce the mock goes in __mocks__/lodash.debounce/index.js

const debounce = jest.fn().mockImplementation((callback, delay) => {
  let timer = null;
  let pendingArgs = null;

  const cancel = jest.fn(() => {
    if (timer) {
      clearTimeout(timer);
    }
    timer = null;
    pendingArgs = null;
  });

  const flush = jest.fn(() => {
    if (timer) {
      callback(...pendingArgs);
      cancel();
    }
  });

  const wrapped = (...args) => {
    cancel();

    pendingArgs = args;
    timer = setTimeout(flush, wrapped.delay);
  };

  wrapped.cancel = cancel;
  wrapped.flush = flush;
  wrapped.delay = delay;

  return wrapped;
});

export default debounce;