Node.js: How to Unit Test an Uncaught Exception Handler

October 11th, 2015 Permalink

In Node.js it is possible to intercept an uncaught exception by adding a listener to the relevant event emitted by the process object:

// It is always a better idea to use a one-time listener if you have to do this.
// Consider what happens if another exception is thrown by the listener.
process.once('uncaughtException', function (error) {
  console.error('Uncaught exception:' , error);
  // Do something here, such as clean up and exit.
});

throw new Error('I am uncaught.');

Just because you can do this doesn't mean that you should, of course. In fact it is almost always a terrible idea: following an exception, parts of the program state will be incorrect or undefined. Blindly carrying on will no doubt cause subtle and damaging bugs. In those rare situations that require handling an uncaught exception via this approach, it is a good idea to do as little as possible and carry out a controlled exit as soon as possible. For example, this is one of the easier ways to clean up before exit following an uncaught exception that occurs in third party asynchronous code.

That said, in general you should be writing application code that can survive having the plug abruptly pulled on it, because that is what happens in the real world. Code that absolutely must clean up on shutdown isn't as safe or reliable as code that doesn't, requires more work to build and validate, and regardless must still cover the case of startup following an uncontrolled shutdown where cleanup didn't happen.

Anyway, let us take it as read that all of this is understood and yet you are still stuck with listening for uncaughtException events as the best path forward. Given that, how do you unit test this code? You'll quickly find that uncaughtException listeners and test frameworks are like oil and water, with outcomes ranging from the framework intercepting any exception before it hits the handler to the framework exploding or otherwise failing to function correctly. One approach that works is disable the uncaughtException listener for the usual run of unit tests, create a test case module that triggers the exception handling, and load it in a subprocess. The parent process can evaluate the output and exit code, with the test case module structured to exit with code 0 on success, as well as sending some suitable string to stdout that can be matched.

The following example code demonstrates how to do this in the context of Mocha BDD interface functions, but any test framework will work.

lib/example.js:

/**
 * @fileOverview A wrapper for calling third party async code.
 */

var thirdParty = require('third-party');
var cleanup = require('./cleanup');

exports.run = function (callback) {
  thirdParty.run(callback);
};

// Set up a handler for problems arising in the third party async code.
// This is created with the understanding that it is the least worst way
// of handling these issues in the time allowed.
process.once('uncaughtException', function (error) {
  // Only handle this exception if the global isn't set. The global should be 
  // set during unit testing to prevent uncaught exception handling from
  // messing up the test framework.
  if (global.suppressUncaughtExceptionHandling) {
    throw error;
  }

  // Perform the necessary cleanup activities and exit.
  cleanup.handleError(error);
});

test/lib/example.spec.js:

/**
 * @fileOverview Unit tests for lib/module.js.
 */

var sinon = require('sinon');
var chai = require('chai');
var expect = chai.expect;
var thirdParty = require('third-party');

// Suppress the uncaught exception handling before loading the module.
global.suppressUncaughtExceptionHandling = true;

var example = require('../../lib/example');

describe('lib/example', function () {
  var sandbox;

  beforeEach(function () {
    sandbox = sinon.sandbox.create();
  });

  afterEach(function () {
    sandbox.restore();
  });

  // Standard unit testing. In this process uncaught exception handling
  // is suppressed. We would expect the Mocha framework to intercept
  // errors beforehand anyway, but if exceptions occur and are not
  // intercepted we want to see them, not hide them.
  describe('run', function () {
    it('invokes thirdParty.run', function (done) {
      sandbox.stub(thirdParty, 'run').yields();
      example.run(function (error) {
        sinon.assert.calledWith(thirdParty.run, sinon.match.func);
        done(error);
      });
    });
  });

  describe('uncaught exception', function () {
    it('correctly handles an uncaught exception', function (done) {
      // This test has to happen in a subprocess, running a test
      // case module where uncaught exception handling is not suppressed,
      // as it is in this process.
      var proc = childProcess.fork(
        path.resolve(__dirname, './uncaughtExceptionTestCase'),
        {
          env: process.env,
          silent: true
        }
      );

      var output = '';

      proc.stdout.on('data', function (data) {
        output += data.toString();
      });

      proc.stderr.on('data', function (data) {
        output += data.toString();
      });

      proc.on('exit', function (code) {
        expect(code).to.equal(0);
        expect(output).to.match(/SUCCESS/);
        done();
      });
    });
  });
});

test/lib/uncaughtExceptionTestCase.js:

/**
 * @fileOverview A test case for uncaught exception handling in lib/example.js.
 *
 * This should be called in a subprocess from within a test framework. On success
 * it will exit with code 0 and stdout will contain the string 'SUCCESS'.
 */

var sinon = require('sinon');
var thirdParty = require('third-party');
var cleanup = require('../../lib/cleanup');
var example = require('../../lib/example');

sinon.stub(cleanup, 'handleError');
sinon.stub(thirdParty, 'run').throws();

// Trigger the uncaught exception.
example.run(function () {});

// Wait a little. This is not necessary here, but may be for real 
// async code, depending on the details leading to the triggered
// exception.
setTimeout(function () {
  sinon.assert.calledWith(
    cleanup.handleError, 
    sinon.match.instanceOf('Error')
  );
  // This will only be sent to stdout if everything has so far worked
  // as planned.
  console.info('SUCCESS');
}, 10);