How to test Handlebars templates with Node.js test runner

Context

Since a year or so I tend to use the test runner that comes natively with Node.js rather than reach out for fancy frameworks like Jest or Vitest. I like to keep my dependencies to a minimum as much as possible.

For the application I'm currently building I needed to unit test my Handlebars templates.

In the past, when I used Jest I resorted to Jest's Code transformation feature to be able to import my template files using the import keyword.
It gave me a compiled template function I could pass some context object to be used to render the Handlebars template.

After some research, I could not find a Node.js test runner way of doing this. I had to come up with something.

Be aware that you need Node.js 22.15+, 23.5+ or above.

This is what I came up with...

The setup

In order to be able to import .hbs, i.e. Handlebars, files with the import keyword and stubble across Node.js' customization hooks.

This is how it goes...

You need a to register your customization hooks. This is done by calling the module.registerHooks() from node:module.

For this I have the following ./src/register.js file:

import module from "node:module";
import { resolve, load } from "./handlebars-custom-hook.js";

if (typeof module.registerHooks === "function") {
  // Node 22.15+ / 23.5+: in-thread, synchronous hooks
  module.registerHooks({ resolve, load });
}

In order to enable this, you will need to include --import ./src/register.js in your node command like so node --import ./src/register.js --test

Before moving to the Handlebars custom hook let's add some configuration in our package.json file so we can register our Handlebars partials and helpers.

I came up with the handlesbars property as shown in the below package.json excerpt.

{
  "scripts": {
    "test": "node --import ./src/register.js --test",
    "test:watch": "node --import ./src/register.js --test --watch"
  },
  "handlebars": {
    "partialsDirectories": ["./src/views/layouts", "./src/views/partials"],
    "helpersDirectories": ["./src/views/helpers"],
    "ignoreList": ["test"]
  },
  "devDependencies": {
    "handlebars": "^4.7.10",
    "jsdom": "^30.1.3"
  }
}

The ignoreList is a list of exact match directory of filename to be ignored when recursively collecting partials and templates from the specified directories.

Now onto the actual Customization hook, the ./src/handlebars-custom-hook.js file...
Be aware it comes, along side of the key resolve and load function with a look of boiler plate code to look for and register Handlebars helpers.

import handlebars from "handlebars";
import path from "node:path";
import { URL } from "node:url";
import { readdirSync, statSync, readFileSync } from "node:fs";
const config = await import(process.env.npm_package_json, {
  with: { type: "json" },
});

const partialsDirectories =
  config.default?.handlebars?.partialsDirectories || [];
const helpersDirectories = config.default?.handlebars?.helpersDirectories || [];
const ignoreListConfig = config.default?.handlebars?.ignoreList || [];
const ignoreList = new Set(ignoreListConfig);

/**
 * Register all `.hbs` files from the given set of partial directories onto the
 * given `handlebars` environment.
 *
 * @param {object} handlebars environment
 * @param {object[]} partialsDirectories Directories to crawl to find `.hbs`
 * file that are supposed to be partials.
 */
function registerPartials(handlebars, partialsDirectories) {
  for (const partialsDirectory of partialsDirectories) {
    const partials = getAllFilesWithExtension(partialsDirectory, ".hbs");

    for (const partial of partials) {
      const { basename, content } = partial;

      handlebars.registerPartial(basename, content);
    }
  }
}

/**
 * Register all `.js` files from the given set of helpers directories onto the
 * given `handlebars` environment.
 *
 * @param {object} handlebars environment
 * @param {object[]} helpersDirectories Directories to crawl to find `.js`
 * file that are supposed to be helpers.
 */
async function registerHelpers(handlebars, helpersDirectories) {
  for (const helpersDirectory of helpersDirectories) {
    const helpers = getAllFilesWithExtension(helpersDirectory, ".js");

    for (const helper of helpers) {
      const { filePath, basename } = helper;

      const packageJsonDirectory = process.env.npm_config_local_prefix;
      const helperCode = await import(`${packageJsonDirectory}/${filePath}`);

      if (typeof helperCode.default === "function") {
        handlebars.registerHelper(basename, helperCode.default);
      } else if (typeof helperCode === "object") {
        for (const helperName in helperCode) {
          if (typeof helperCode[helperName] === "function") {
            handlebars.registerHelper(helperName, helperCode[helperName]);
          }
        }
      }
    }
  }
}

/**
 * Recursively get all the files from the given directory with the given
 * extension.
 *
 * @param {string} directory Path of current directory being crawled.
 * @param {string} extension Extension of files being collected.
 * @param {string[]} [files] List of files from the current directory being crawled.
 * @param {object[]} [result] List of object that holds collected files
 * `filePath`, `basename` and `content`.
 *
 * @returns {object[]} List of object that holds recursively collected files
 * `filePath`, `basename` and `content`.
 */
function getAllFilesWithExtension(directory, extension, files, result) {
  files = files || readdirSync(directory);
  result = result || [];
  const regex = new RegExp(`\\${extension}$`);

  for (const file of files) {
    if (ignoreList.has(file)) {
      continue;
    }

    const filePath = path.join(directory, file);
    if (statSync(filePath).isDirectory()) {
      try {
        result = getAllFilesWithExtension(
          filePath,
          extension,
          readdirSync(filePath),
          result,
        );
      } catch {
        continue;
      }
    } else if (regex.test(filePath)) {
      try {
        const basename = path.basename(filePath, extension);
        const content = readFileSync(filePath, "utf8");

        result.push({ filePath, basename, content });
      } catch (error) {
        console.error(error);
      }
    }
  }

  return result;
}

/* The HandlebarsEnvironment module that exposes it's `init()` function which in
 * turns return the `handlebars` environment configured with collected partials
 * and helpers */
const HandlebarsEnvironment = (function () {
  async function init(options) {
    const { partialsDirectories, helpers } = options;

    if (Array.isArray(partialsDirectories) && partialsDirectories.length > 0) {
      registerPartials(handlebars, partialsDirectories);
    }

    if (Array.isArray(helpersDirectories) && helpersDirectories.length > 0) {
      // if (typeof helpers === 'object' && helpers !== null) {
      //  handlebars.registerHelper(helpers);
      // }
      await registerHelpers(handlebars, helpersDirectories);
    }

    return handlebars;
  }

  return {
    init,
  };
})();

const handlebarsEnv = await HandlebarsEnvironment.init({
  partialsDirectories: [...partialsDirectories],
  helpersDirectories: [...helpersDirectories],
});

/**
 * Module resolver for Handlebars.
 * Take an `import` or `require` specifier and resolve it to a URL.
 *
 * See https://nodejs.org/api/module.html#synchronous-resolvespecifier-context-nextresolve
 */
function resolve(specifier, context, nextResolve) {
  return nextResolve(specifier, context);
}

/**
 * Module loader for Handlebars.
 * Take a resolved URL and return the source code to be evaluated.
 *
 * See https://nodejs.org/api/module.html#synchronous-loadurl-context-nextload
 */
function load(url, context, nextLoad) {
  if (url.endsWith(".hbs")) {
    const hbsText = readFileSync(new URL(url), "utf8");
    const hbsHook = import.meta.filename;

    return {
      format: "module",
      shortCircuit: true,
      source: `
        import path from 'node:path';
        const {handlebarsEnv} = await import('${hbsHook}');

        export default handlebarsEnv.compile(\`${hbsText}\`);`,
    };
  }

  return nextLoad(url, context);
}

export { handlebarsEnv, resolve, load };

Greeting partial, a concrete example

For this example let's re-use and tweak slightly the Handlebars' playground example.

Let's create a greeting ./src/views/partials/greeting.hbs:

Hello {{firstname}} {{loud lastname}}

It comes together with a loud helper to be found under ./src/views/helpers/loud.js

export default function (aString) {
  return aString.toUpperCase();
}

And finally, the final piece to the puzzle, a basic ./src/views/partials/test/greeting.test.js unit tests file that only covers the happy path 😉!

import { JSDOM } from "jsdom";
import assert from "node:assert/strict";
import { before, describe, it } from "node:test";
import greeting from "../greeting.hbs";

describe("Greeting partial", () => {
  let document;

  before(() => {
    ({ document } = new JSDOM().window);
  });

  it('renders Hello followed by the first name and then the last name upper-cased"', () => {
    const firstname = "Yehuda";
    const lastname = "Katz";
    const input = {
      firstname,
      lastname,
    };

    document.body.innerHTML = greeting({
      ...input,
    });

    assert.equal(
      document.body.innerHTML.trim(),
      `Hello ${firstname} ${lastname.toUpperCase()}`,
    );
  });
});

In the above file you also see how JSDOM comes into the equation.

Now when running npm run test src/views/partials/test/greeting.test.js, I get the following output on the command line:

â–¶ Greeting partial
  ✔ renders Hello followed by the first name and then the last name upper-cased" (5.686414ms)
✔ Greeting partial (46.918202ms)
ℹ tests 1
ℹ suites 1
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 383.466277

Et voilà! Happy code covering!

References