How to Write a Test Runner for Vanilla JS Project

2 阅读2分钟

持续更新中……

WHY

如何给下面的函数做单测?

'use strict'
{
  getLeafCount(node) {
      ....
  }
}

如果是

exports.getLeafCount = () => {
  ...
}

我们很容易利用现有单测框架做测试。但因非 CJS 也不是 ESM。我们没法拿到 getLeafCount

为什么加 use strict 详见:Safari iOS ReferenceError: Can't find variable: xxx

在非模块化的开发中,即采用传统的裸写的 script 方式开发的应用如何进行单元测试? 本文提供一种思路,通过将非模块化的 JS 转成模块化的然后在 Node.js 环境中进行单测。

转换方式可以使用正则表达式也可使用 AST,本文采用 AST,相比可读性更好也更精准无误。

前言

涉及到的技术名词

  • AST

技术方案

Code (非模块化) => AST => Code (CJS)

技术选型

  • code => ast:@babel/parser
  • 遍历 ast:@babel/traverser
  • ast => code @babel/generator
  • 单测框架:Node.js 内置 import { ... } from "node:test"

一些开发技巧

  • .mjs 而非 .js:这样 js 文件也可以直接写 import export 当然最主要是想用 top level await。
  • // @ts-check 即使 JS 也充分利用 TS 的严格类型校验。
  • tsdoc:给 JS 标注类型。

如何选择生成 ast 的库?或者为什么选择 @babel/parser?后续会讲到。

涉及到的工具

核心代码

// compile.mjs
// @ts-check
// @ts-expect-error
import { glob, readFile, mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';

import { parse } from '@babel/parser';
import traverser from '@babel/traverse';
import generator from '@babel/generator';

// @ts-expect-error
const traverse = traverser.default;
// @ts-expect-error
const generate = generator.default;

const TAG = '@test';

console.time('[INFO] test build costs');
await main();
console.log();
console.timeEnd('[INFO] test build costs');

async function main() {
  const promises = [];

  // !@warn node>=22.0.0 required for `glob`
  for await (const entry of glob('./static/js/**/*.js')) {
    debug('Find JS file in static/js', entry);

    promises.push(compile(entry));
  }

  return Promise.all(promises);
}

async function compile(fp) {
  const content = await readFile(fp, 'utf8');

  const ast = parse(content);
  const fns = getFunctionsToTest(ast);

  if (!fns.length) {
    return;
  }

  info(fns.length, `functions with \`${TAG}\` comment found in`, `"${fp}".`);

  const publicFunctions = fns.map(({ code, name }) => {
    return makeFunctionPublic({
      code,
      name,
    });
  });

  return saveToFile(publicFunctions.join('\n'), fp);
}

/**
 * @param {{ code: string; name: string }} params
 * @returns {string}
 */
function makeFunctionPublic({ code, name }) {
  const prefix = `\nexports.${name} = ${name};`;

  return prefix + '\n' + code;
}

/**
 *
 * @param {string} content
 * @param {string} fp
 */
async function saveToFile(content, fp) {
  const dist = `./tests/__auto-generated/${fp}`;
  await mkdir(dirname(dist), { recursive: true });
  await writeFile(
    dist,
    '// !Auto generated by tests/compile.mjs.\n// !Should not be modified manually.\n' +
      content
  );

  info('Public functions saved to', `"${dist}".`);
}

/**
 *
 * @param {ReturnType<typeof parse>} ast
 * @returns {Array<{ code: string; name: string; }>}
 */
function getFunctionsToTest(ast) {
  /** @type {Array<{ node: object; }>}  */
  const functions = [];
  let hasTag = false;

  traverse(ast, {
    FunctionDeclaration: function (path) {
      functions.push({ node: path.node });

      if (path.node.leadingComments?.[0].value.includes(TAG)) {
        hasTag = true;
      }
    },
  });

  if (!hasTag) {
    return [];
  }

  return functions.map(({ node }) => {
    const output = generate(node);

    return {
      code: output.code,
      name: node.id.name,
    };
  });
}

function debug(...msg) {
  if ('DEBUG' in process.env) console.log('[DEBUG]', ...msg);
}

function info(...msg) {
  console.info('[INFO]', ...msg);
}

Improvements or TODO

  • rust 来写 compile
  • 仅导出被单测函数使用到的函数