Clever Engineering Blog — Always a Student

Testing Private Functions in JavaScript Modules

By clevereng on

As JavaScript has matured as a language, the module has become the primary unit of code organization. However, as with many facets of JS, modules grew organically from the developer ecosystem (as opposed to being designed as part of the language from the beginning), so they have their flaws – one being that you can only export one interface, and any functions not exported through the interface are completely inaccessible, even to tests.

Whether you use RequireJS to modularize client-side JS, Node.js’s built-in module support for server-side JS, or any other implementation of the CommonJS module specification, the basic usage pattern is the same: within a modules definition scope, any fields you add to the exports object will be available in other code that loads the module using the require function.

For instance, in Node.js, let’s define a module that exports some simple statistical functions:

// file: stats.js
var sum = function (nums) {
  return nums.reduce(function (total, num) { return total + num; }, 0);
};
module.exports = {
  mean: function (nums) { return sum(nums) / nums.length; },
  standardDeviation: function (nums) {
    var mean = this.mean(nums);
    var variance = sum(nums.map(function (num) {
      return Math.pow(num - mean, 2);
    }));
    return Math.sqrt(variance / (nums.length - 1));
  }
};

If we want to write tests for these functions in a separate module, we can require it like so:

// file: test-stats.js
var assert = require('assert');
var stats = require('./stats');
assert.equal(stats.mean([1]), 1);
assert.equal(stats.mean([1, 2, 3]), 2);
assert.equal(stats.standardDeviation([1, 2, 3]), 1);

However, if we want to also test our private function sum, which isn’t exported by the stats module, we can’t access it.

We could access it if it were exported alongside mean and standardDeviation, but we don’t want it to be part of the public interface of the module.

One common convention in scripting languages like JS is to export private functions publicly, but prefix their names with an underscore to denote that they should not be considered part of the public interface, like so:

// file: stats.js
var sum = function (nums) { ... };
module.exports = {
  mean: function (nums) { ... },
  standardDeviation: function (nums) { ... },
  _sum: sum
};

Although this convention is widely followed, without a way to enforce privacy, consumers of the module may end up ignoring the convention and using the function anyway.

In order to enforce that private functions that are exported publicly can’t be used by consumers of the module, we use an environment variable that denotes whether the code is being required for tests (in Node.js, this is NODE_ENV). Also, to make it clear that the functions are private and to avoid cluttering the public interface, we export them all in one object under the _private field:

// file: stats.js
var sum = function (nums) { ... };
module.exports = {
  mean: function (nums) { ... },
  standardDeviation: function (nums) { ... },
};
if (process.env.NODE_ENV === 'test') {
  module.exports._private = { sum: sum };
}

Now we can access sum in our tests, but not in production code.

// file: test-stats.js
var assert = require('assert');
var stats = require('./stats');
...
assert.equal(stats._private.sum([1]), 1);
assert.equal(stats._private.sum([1, 2, 3]), 6);

With CoffeeScript, which is what we use at Clever, these patterns come out clean and concise thanks to object destructuring and postfix conditionals:

# file: stats.coffee
sum = (nums) -> ...
module.exports =
  mean: (nums) -> ...
  standardDeviation: (nums) -> ...
module.exports._private = {sum} if process.env.NODE_ENV is 'test'
# file: test-stats.coffee
assert = require 'assert'
stats = require './stats'
{sum} = stats._private;
...
assert.equal(sum([1]), 1);
assert.equal(sum([1, 2, 3]), 6);

Lowering the barriers to testing private functions has helped us focus on writing more unit tests and more modular code. Hopefully it can yield similar benefits for you. Happy testing!

Comments on Hacker News.