How to Unit Test Private (Non-Exported) Functions in Node.js with Mocha

In Node.js, modules encapsulate code, exposing only explicitly exported functions and variables as public APIs. Functions that are not exported—often called "private" or "non-exported" functions—remain hidden within the module’s scope. While testing public APIs is straightforward, testing private functions can be tricky. These functions often contain critical business logic, edge-case validations, or complex calculations that directly impact the reliability of your application.

This blog will demystify the process of unit testing private functions in Node.js using Mocha, a popular testing framework. We’ll explore why you might want to test private functions, compare different methods to access them, and share best practices to ensure your tests are maintainable and effective.

Table of Contents#

  1. Understanding Private Functions in Node.js
  2. Should You Test Private Functions?
  3. Methods to Test Private Functions
  4. Best Practices
  5. Conclusion
  6. References

1. Understanding Private Functions in Node.js#

In Node.js, modules use the CommonJS (CJS) or ES Modules (ESM) system to encapsulate code. For simplicity, we’ll focus on CommonJS (the traditional require/module.exports syntax) here, as it’s still widely used and aligns with how most Node.js projects structure private functions.

A private function is any function declared in a module that is not added to module.exports (or exports). These functions are only accessible within the module’s scope and cannot be directly imported by other modules.

Example: A Module with Private and Public Functions#

Consider a math-utils.js module with a public add function that relies on a private validateNumber function to ensure inputs are valid numbers:

// math-utils.js
 
// Private function (not exported)
function validateNumber(input) {
  if (typeof input !== 'number' || isNaN(input)) {
    throw new Error(`Invalid number: ${input}`);
  }
}
 
// Public function (exported)
function add(a, b) {
  validateNumber(a); // Uses private function
  validateNumber(b);
  return a + b;
}
 
// Export public API
module.exports = { add };

Here, add is public (exported), while validateNumber is private (only accessible inside math-utils.js).

2. Should You Test Private Functions?#

The debate over testing private functions is long-standing. Purists argue that:

  • Private functions are implementation details: They exist to support public APIs, and testing them couples tests to the module’s internal structure, making refactoring harder.
  • Tests should validate behavior, not implementation: Public APIs define the module’s behavior, so tests should focus on inputs/outputs of public functions.

However, there are valid cases to test private functions directly:

  • Complex logic: If a private function handles critical, multi-step logic (e.g., data parsing, validation), direct tests can catch edge cases that public API tests might miss.
  • Debugging: Testing private functions isolates failures, making it easier to pinpoint issues (e.g., "Is the bug in add or validateNumber?").

Rule of thumb: Prefer testing through public APIs unless the private function is complex enough to warrant direct testing.

3. Methods to Test Private Functions#

Let’s explore practical methods to test private functions in Node.js using Mocha (a testing framework) and Chai (an assertion library). We’ll use the math-utils.js example above for consistency.

Prerequisites#

First, set up a test environment:

  1. Initialize a Node.js project: npm init -y
  2. Install dependencies:
    npm install --save-dev mocha chai  # Testing framework and assertions
  3. Add a test script to package.json:
    { "scripts": { "test": "mocha tests/**/*.test.js" } }

Method 1: Export Private Functions Conditionally#

The simplest way to test private functions is to export them only in testing environments. This avoids polluting the production API while exposing functions to tests.

How to Implement:#

Modify the module to export private functions when NODE_ENV=test. Use process.env.NODE_ENV to detect the environment.

Update math-utils.js:

// math-utils.js
 
function validateNumber(input) { /* ... */ } // Private function
 
function add(a, b) { /* ... */ } // Public function
 
// Export public API
const publicAPI = { add };
 
// Conditionally export private functions in test mode
if (process.env.NODE_ENV === 'test') {
  publicAPI.validateNumber = validateNumber; // Add private function to exports
}
 
module.exports = publicAPI;

Test the Private Function:#

In your test file (e.g., tests/math-utils.test.js), set NODE_ENV=test and import the private function:

// tests/math-utils.test.js
const { expect } = require('chai');
const { add, validateNumber } = require('../math-utils');
 
// Test private function directly
describe('validateNumber (private)', () => {
  it('throws an error for non-numeric inputs', () => {
    expect(() => validateNumber('not-a-number')).to.throw('Invalid number: not-a-number');
    expect(() => validateNumber(NaN)).to.throw('Invalid number: NaN');
  });
 
  it('does not throw for valid numbers', () => {
    expect(() => validateNumber(5)).to.not.throw();
    expect(() => validateNumber(-3.14)).to.not.throw();
  });
});
 
// Test public function (as usual)
describe('add (public)', () => {
  it('returns the sum of two numbers', () => {
    expect(add(2, 3)).to.equal(5);
  });
});

Run Tests:#

Set NODE_ENV=test when running tests to expose private functions:

NODE_ENV=test npm test

Pros/Cons:#

  • Pros: Simple, no external dependencies.
  • Cons: Pollutes the public API in test mode (though harmless if production uses NODE_ENV=production).

Method 2: Using rewire#

rewire is a Node.js library that lets you modify a module’s private scope to access non-exported functions/variables. It works by wrapping require and adding methods like __get__ (to fetch private values) and __set__ (to modify them).

How to Implement:#

  1. Install rewire:

    npm install --save-dev rewire
  2. Use rewire in tests to load the module and fetch the private function:

// tests/math-utils.test.js
const { expect } = require('chai');
const rewire = require('rewire');
 
// Load the module with rewire (instead of require)
const mathUtils = rewire('../math-utils');
 
// Fetch the private function using __get__
const validateNumber = mathUtils.__get__('validateNumber');
 
describe('validateNumber (private)', () => {
  it('throws an error for non-numeric inputs', () => {
    expect(() => validateNumber('not-a-number')).to.throw('Invalid number: not-a-number');
  });
 
  it('accepts valid numbers', () => {
    expect(() => validateNumber(42)).to.not.throw();
  });
});

How It Works:#

rewire('../math-utils') loads the module and adds __get__(key) to retrieve variables/functions from the module’s scope. Here, mathUtils.__get__('validateNumber') extracts the private function.

Pros/Cons:#

  • Pros: No changes to production code; clean separation of test and production logic.
  • Cons: Relies on a third-party library (rewire).

Method 3: Testing Through Public Interfaces#

The "traditional" approach: Test private functions indirectly by invoking public functions that depend on them. This aligns with the "test behavior, not implementation" philosophy.

How to Implement:#

Use public functions to trigger the private function’s logic. For example, test validateNumber by passing invalid inputs to add and asserting that errors are thrown.

// tests/math-utils.test.js
const { expect } = require('chai');
const { add } = require('../math-utils');
 
describe('add (public)', () => {
  it('throws an error if inputs are invalid (indirectly tests validateNumber)', () => {
    // Test validateNumber via add: pass invalid inputs
    expect(() => add('5', 3)).to.throw('Invalid number: 5'); // Non-numeric input
    expect(() => add(NaN, 10)).to.throw('Invalid number: NaN'); // NaN input
  });
 
  it('returns the sum for valid inputs', () => {
    expect(add(2, 3)).to.equal(5);
  });
});

Pros/Cons:#

  • Pros: No production code changes; tests remain decoupled from internal logic.
  • Cons: May not cover all edge cases of the private function (e.g., if add only uses validateNumber twice, but validateNumber has 10 edge cases).

Bonus: Refactoring to Public Utility Modules#

If a private function is complex, consider moving it to a public utility module. This eliminates the need for "hacks" like rewire and makes the function testable by design.

Example:#

  1. Create a new module validators.js and export validateNumber:

    // validators.js
    function validateNumber(input) {
      if (typeof input !== 'number' || isNaN(input)) {
        throw new Error(`Invalid number: ${input}`);
      }
    }
     
    module.exports = { validateNumber }; // Now public
  2. Update math-utils.js to import validateNumber:

    // math-utils.js
    const { validateNumber } = require('./validators'); // Import public utility
     
    function add(a, b) {
      validateNumber(a);
      validateNumber(b);
      return a + b;
    }
     
    module.exports = { add };
  3. Test validateNumber directly in validators.test.js:

    // tests/validators.test.js
    const { expect } = require('chai');
    const { validateNumber } = require('../validators');
     
    describe('validateNumber (public utility)', () => {
      it('throws for non-numeric inputs', () => {
        expect(() => validateNumber('test')).to.throw('Invalid number: test');
      });
    });

Pros/Cons:#

  • Pros: Clean, maintainable, and aligns with modular design principles.
  • Cons: Requires refactoring (not always feasible for legacy code).

4. Best Practices#

  1. Prefer refactoring over hacks: If a private function needs testing, consider moving it to a utility module (Method 4) instead of using rewire or conditional exports.
  2. Limit conditional exports to tests: Ensure private functions are not exported in production (use NODE_ENV=production checks).
  3. Avoid over-testing: Only test private functions if they’re critical or complex. Most private functions can be tested indirectly via public APIs.
  4. Use rewire sparingly: Reserve it for legacy code or cases where refactoring is impractical.

5. Conclusion#

Testing private functions in Node.js is not black and white. While the "test public APIs only" mantra holds for most cases, direct testing of private functions can add value for complex logic.

  • Use rewire for quick access to private functions without modifying production code.
  • Conditional exports work for simple projects but pollute the API in test mode.
  • Testing through public APIs is best for maintainability.
  • Refactoring to utility modules is the cleanest long-term solution.

Choose the method that aligns with your project’s needs, and always prioritize test reliability and maintainability.

6. References#