1

This setup is extremely specific but I couldn't find any similar resources online so I'm posting here in case it helps anyone.


There are many questions about Jest and Async callback was not invoked, but I haven't found any questions whose root issue revolves around the use of jest.useFakeTimers(). My function should take no time to execute when using fake timers, but for some reason Jest is hanging.

I'm using Jest 26 so I'm manually specifying to use modern timers.

This is a complete code snippet that demonstrates the issue.

jest.useFakeTimers('modern')
let setTimeoutSpy = jest.spyOn(global, 'setTimeout')

async function retryThrowable(
  fn,
  maxRetries = 5,
  currentAttempt = 0
) {
  try {
    return await fn()
  } catch (e) {
    if (currentAttempt < maxRetries) {
      setTimeout(
        () => retryThrowable(fn, maxRetries, currentAttempt + 1),
        1 * Math.pow(1, currentAttempt)
      )
    }
    throw e
  }
}

describe('retryThrowable', () => {
  const fnErr = jest.fn(async () => { throw new Error('err') })

  it('retries `maxRetries` times if result is Err', async () => {
    jest.clearAllMocks()
    const maxRetries = 5

    await expect(retryThrowable(() => fnErr(), maxRetries)).rejects.toThrow('err')

    for (let _ in Array(maxRetries).fill(0)) {
      jest.runAllTimers()
      await Promise.resolve() // https://stackoverflow.com/a/52196951/3991555
    }

    expect(setTimeoutSpy).toHaveBeenCalledTimes(maxRetries)
  })
})

The full error message is

Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Error: Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.

      at mapper (../../node_modules/jest-jasmine2/build/queueRunner.js:27:45)

Any ideas would be very appreciated


edit 1: I have tried --detectOpenHandles but no new information is provided


edit 2: I just tried my above code snippet in a fresh project and realized that it passes just fine. So the issue must somewhere else in my Jest config. I'll answer my own question when I determine the root cause

Eric Dauenhauer
  • 710
  • 6
  • 23

1 Answers1

2

My issue ended up being in my jest configuration.

We execute tests directly against an in-memory DB, and to keep our tests clean we wrap each test in a DB transaction. Jest doesn't provide a native aroundEach hook like many other test runners, so we achieved this by monkey-patching the global test and it functions so we could execute the test callback inside a transaction. Not sure if it matters but to be explicit we are using Sequelize as our ORM and for transactions.

The test I was executing (as seen above) recursively called setTimeout with a function which threw an error / rejected a Promise. Sequelize transactions apparently do not appreciate unhandled rejections, and it was causing the test to hang. I never was able to get to the root of why the test was hanging; the transaction successfully rolled back and all test expectations were run, but for some reason the test never exited.

Solution #1 (not great)

My first solution is not pretty but it is pragmatic. I simply extended the Jest test function with a variant which does not use the monkey-patched test.

// jest.setup.ts
declare namespace jest {
  interface It {
    noDb: (name: string, fn?: ProvidesCallback, timeout?: number) => void
  }
}

it.noDb = it
// jest.config.js
module.exports = {
  // ...
  setupFilesAfterEnv: [
    './jest.setup.ts', // <-- inject `it.noDb` method
    './jest.mokey-patch.ts', // <-- monkey-patching
  ],
}

Then, I modified the test from the OP to call this new function

it.noDb('retries `maxRetries` times if result is Err', ...

More details on how and why this extension works can be found in this blog post.

Solution #2 (better)

After messing with this more, I realized that the root issue was that there were unhandled promise rejections happening in the main thread. I'm not sure why this conflicted with Sequelize Transactions but suffice to say it's bad practice anyway.

I was able to avoid the issue entirely, as well as any bizarre Jest extensions, by simply fixing the method to only throw on the first call. This way, we can handle errors when we call retryThrowable but do not throw errors on subsequent calls.

  // ...
  try {
    return await fn()
  } catch (e) {
    if (currentAttempt < maxRetries) {
      setTimeout(
        () => retryThrowable(fn, maxRetries, currentAttempt + 1),
        1 * Math.pow(1, currentAttempt)
      )
    }

    //  this is the new part
    if (currentAttempt === 0) {
      throw e
    }
  }
  // ...
Eric Dauenhauer
  • 710
  • 6
  • 23