Skip to content
Johann Pardanaud

How to assert process events with Jest

I'm not a long-time user of Jest, I've always used it sporadically. But lately I've being coding more frequently with it and wrote more advanced use cases.

One of the "advanced" use cases is to assert if an exception is properly thrown inside a setTimeout callback, here's a simple example with a function to limit the execution time of our Node process:

function setMaximumExecutionTimeForNode(maxTime) {
  setTimeout(() => {
    throw new Error("Maximum execution time has been reached.")
  }, maxTime)
}

My first solution was to add a listener on the uncaughtException of the process variable and wait for it to be triggered:

test("exception is thrown when maximum execution time is reached", async () => {
  // Resolve the promise once the error as been caught by the listener
  const uncaughtErrorPromise = new Promise(resolve => {
    process.on("uncaughtException", resolve)
  })

  // Set our maximum execution time to 0 to trigger as soon as possible
  setMaximumExecutionTimeForNode(0)

  // Expect our promise to resolve with the error
  await expect(uncaughtErrorPromise).resolves.toBeInstanceOf(Error)
})

Unfortunately, this doesn't work out of the box, you are told an uncaught exception occured but you are unable to intercept it.

Jest output showing our test failed with an uncaught exception

This happens because Jest mocks the process variable! When you call process.on(), you add your listener to an EventEmitter which is not the original one and which will never be called when an uncaught exception occurs.

This is not something expected by Jest users and it brings problems for everyone. To fix this, we need to add our listener on the original process instance. But the real issue here is that Jest doesn't provide any API to retrieve the original global objects it mocks.

A solution is to store the original process instance somewhere before Jest starts, we can do this by creating a bootstrap file:

#!/usr/bin/env node

// Pass the original process to a closure stored in the `process._original` property.
// Use a closure to ensure the stored value will not change when running tests.
process._original = (function (_original) {
  return function () {
    return _original
  }
})(process)

// Run Jest
require("jest/bin/jest")

Now, instead of running the jest command, we can make our file executable with chmod +x .bin/jest and run it directly in our terminal by typing .bin/jest. If you use NPM to run your tests, simply replace the jest command with the bootstrap file:

{
  "scripts": {
    "test": ".bin/jest"
  }
}

Finally, we can replace our usage of process by process._original():

test("exception is thrown when maximum execution time is reached", async () => {
  // Resolve the promise once the error as been caught by the listener
  const uncaughtErrorPromise = new Promise(resolve => {
    process._original().on("uncaughtException", resolve)
  })

  // Set our maximum execution time to 0 to trigger as soon as possible
  setMaximumExecutionTimeForNode(0)

  // Expect our promise to resolve with the error
  await expect(uncaughtErrorPromise).resolves.toBeInstanceOf(Error)
})

However, when you run Jest, you can see the test fails in the same way as on our first try, because Jest registers its own listeners. To fix that you have to remove the listeners before running your test and restore them right after:

// Store the original listeners in an object containing
// an array for each event we need to alter
const originalJestListeners = {
  uncaughtException: [],
  unhandledRejection: [],
}

// For each event, we retrieve the registered listeners, store them
// in our global object and remove them from the event emitter.
beforeEach(() => {
  const originalProcess = process._original()
  Object.keys(originalJestListeners).forEach(event => {
    originalProcess.listeners(event).forEach(listener => {
      originalJestListeners[event].push(listener)
      originalProcess.off(event, listener)
    })
  })
})

// For each event, we retrieve the listeners stored in the global object
// and we register them on the event emitter.
afterEach(() => {
  let listener
  Object.keys(originalJestListeners).forEach(event => {
    while ((listener = originalJestListeners[event].pop()) !== undefined) {
      process._original().on(event, listener)
    }
  })
})

Now our test succeeds! 🎉

Jest output showing our test has succeeded

In case you were wandering how to assert if a promise rejection was unhandled, it's pretty much the same:

test("promise is rejected", async () => {
  const unhandledRejectionPromise = new Promise(resolve => {
    process._original().on("unhandledRejection", resolve)
  })

  Promise.reject("test")

  await expect(unhandledRejectionPromise).resolves.toBe("test")
})

That's it, we're now able to assert if an exception was uncaught or if a promise rejection was unhandled. We're not using any public API so we must be careful because this is an undocumented area, however I don't think this will easily break since we're not removing or disabling the mocks.

I'm Johann Pardanaud, a freelance Back-End developer available to work on your projects! I can help you architect and code your projects by taking advantage of microservices, thoughtful database structures, and asynchronous flows.