Skip to content

Instantly share code, notes, and snippets.

@dfkaye
Last active October 23, 2020 05:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dfkaye/57fd3be707db9e23371c685a4129b5cb to your computer and use it in GitHub Desktop.
Save dfkaye/57fd3be707db9e23371c685a4129b5cb to your computer and use it in GitHub Desktop.
Testing Private Functions in JavaScript: 1) Don't, and 2) Use the Function() constructor

Testing Private Functions in JavaScript

Just Don't

Do not test private functions. Test your public API.

If you must...

If private functions require testing, you can use one of three approaches I know.

1. Move private functions to their own modules so you can test them directly.

Make them part of your public API, the API you already test.

2. Have private functions return both input and output values.

Return data that includes the input as well as the computed output. Update the public functions that use them to accept these structures instead of blindly accepting values. You can pass those back in the output from the public functions.

However, now we're passing whole I/O structures arounds. That can work, but it seems like overkill.

If you find you must test private functions, but don't want to (or just can't) export them as public functions, there is a way.

3. Use the Function() constructor

If you have access to the source file (not the module) as text, you can pass the file's text content plus your test functions as a concatenated string in the body (last) param to JavaScript's Function constructor (a form of eval).

Here's a proof of this concept.

var api = (function() {
  // private
  function double(value) {
    return value * 2;
  }

  // public
  return function api (value) {
    return +(value) === +(value)
      ? double(value)
      : { toString() { return `param [${value}] is not functionally a number` } };
  }
})();

var suite = (function() {
  function testPublic() {
    console.log("testing public function, api");
    console.log(api('bonk').toString());  // should see param message
    console.assert(api(2) === 4, "public function api(2) == 4 should pass");
    console.assert(api(3) === 4, "public function api(3) == 4 should fail");
  }
  
  function testPrivate() {
    console.log("testing private function, double");
    console.log(double('bonk').toString());  // should see "NaN"
    console.assert(double('x') === 10, "private function double('x') === 10 should fail");
    console.assert(double(5) === 10, "private function double(5) === 10 should pass");
  }

  // Add this string variable assignment of a function containing calls to your public and private as an IIFE.
  var tests = `
  (function () {
    testPublic();
    testPrivate();
  }());
  `;
  
  // return the string concatenation of the functions and the test IIFE.
  return [ testPublic, testPrivate, tests ].join(";\n")
})();

/* test it out */

/*
 * @function exec encapsulates our join and run logic:
 * + join api and suite
 * + pass as body param to Function()
 * + exec on 0 as context (this) so it does not leak.
 */
function exec(...parts) {
  var body = [...parts].join(";\n");
  var exec = Function(body);
  exec(0);
}

exec(api, suite);

/*

// Should see the following console output

testing public function, api
param [bonk] is not functionally a number
Assertion failed: public function api(3) == 4 should fail

testing private function, double
NaN
Assertion failed: private function double('x') === 10 should fail
*/

try

In Node.JS, we would require or import the test library, then read the source file with a Node.JS FileReader, do the same for the test file, and pass their contents to our exec() function.

In a browser test page we could load the testing library, then xhr|fetch the source, then xhr|fetch the test, then concatenate their contents using the exec() function.

catch

A major problem with this approach comes up when a module under test requires other dependencies. You'll then need a bundler like browserify or a server-client-agnostic bundler to resolve the require|import paths and concatenate those, too. I don't think I'd want to do that.

finally

So, yes, there's more process involved, it will be slower as it is an additional build step, but, yes, it can be done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment