[Unit testing Jest] 手写简易版测试框架

手写简易版测试框架

本小节,我将带着大家一些手写一个简易版的测试框架,部分模块为了方便,我们会直接使用 Jest 所提供的模块,通过手写简易版的测试框架,大家能够体会到一个测试框架是如何搭建起来的。

整个书写过程我们会分为如下 3 步骤:

  • 获取所有测试文件
  • 并行的运行测试代码
  • 添加断言

获取所有测试文件

首先第一步,我们需要搭建我们的项目,假设我们的测试框架叫做 Best,打开终端,输入如下的指令:

cd desktop
mkdir best
cd best
npm init -y
mkdir tests
echo "expect(1).toBe(2);" > tests/01.test.js
echo "expect(2).toBe(2);" > tests/02.test.js
echo "expect(3).toBe(4);" > tests/03.test.js
echo "expect(4).toBe(4);" > tests/04.test.js
echo "expect(5).toBe(6);" > tests/05.test.js
echo "expect(6).toBe(6);" > tests/06.test.js
touch index.mjs
npm i glob

这里我们安装了 glob 这个依赖包,这是一个用于匹配文件路径模式的库。它使开发人员能够使用通配符(例如 * 和 ?)轻松地查找和匹配文件。

// index.mjs
import { glob } from "glob";

const testFiles = glob.sync("**/*.test.js");

console.log(testFiles); // ['tests/01.test.js', 'tests/02.test.js', …]˝

如果你运行上面的代码,会打印出所有的测试文件,当然我们也可以选择使用 jest-haste-map 这个依赖,这是 Jest 测试框架的一个依赖项,提供了一个快速的文件查找系统。它负责构建项目中所有文件及其依赖的映射,以便 Jest 可以快速高效地找到运行测试所需的文件。

npm i jest-haste-map
import JestHasteMap from 'jest-haste-map';
import { cpus } from 'os';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

// 这行代码使用 import.meta.url 获取当前文件的 URL
// 然后使用 fileURLToPath() 函数将其转换为文件路径
// 最后使用 dirname() 函数获取该文件的目录路径作为项目的根目录。
// console.log(import.meta.url); // file:///Users/jie/Desktop/best-test-framework/index.mjs
const root = dirname(fileURLToPath(import.meta.url));

// 这部分代码定义了一个名为 hasteMapOptions 的对象
// 包含了 jest-haste-map 的配置选项,例如要处理的文件扩展名、工作进程的数量等。
const hasteMapOptions = {
  extensions: ['js'], // 只遍历 .js 文件
  maxWorkers: cpus().length, // 并行处理所有可用的 CPU
  name: 'best', // 用于缓存的名称
  platforms: [], // 只针对 React Native 使用,这里不需要
  rootDir: root, // 项目的根目录
  roots: [root], // 可以用于只搜索 `rootDir` 中的某个子集文件
};

// 这行代码使用 JestHasteMap 类创建了一个 hasteMap 实例,并将 hasteMapOptions 对象传递给其构造函数。
const hasteMap = new JestHasteMap.default(hasteMapOptions);
// 这行代码是可选的,用于在 jest-haste-map 版本 28 或更高版本中设置缓存路径。
await hasteMap.setupCachePath(hasteMapOptions);

// 这行代码调用 build() 函数编译项目
// 并从返回的结果中获取 hasteFS 对象,它包含了项目中的所有文件信息。
const { hasteFS } = await hasteMap.build();
// 获取所有的文件
// const testFiles = hasteFS.getAllFiles();
// 我们并不需要获取所有的 js 文件,而是获取 test.js
const testFiles = hasteFS.matchFilesWithGlob(['**/*.test.js']);

console.log(testFiles);
// ['/path/to/tests/01.test.js', '/path/to/tests/02.test.js', …]

至此,我们完成了第一步,获取所有测试文件。

并行的读取测试代码

接下来我们进入到第二步,并行的运行所有的测试代码。

// index.mjs
import fs from 'fs';

await Promise.all(
  Array.from(testFiles).map(async (testFile) => {
    const code = await fs.promises.readFile(testFile, 'utf8');
    console.log(testFile + ':\n' + code);
  }),
);

通过上面的代码,我们读取出了所有测试文件里面所写的内容,但是此时并不是并行执行的,在 JavaScript 中所有的代码都是单线程执行,这意味着在同一个循环中运行测试,它们将无法并发执行。如果我们想要构建一个快速的测试框架,我们需要使用所有可用的 CPU

Node.js 里面有针对 worker threads(工作线程) 的支持,这允许在同一个进程中的多个线程并行处理工作。这需要一些样板代码,因此我们将使用 jest-worker 包:

npm i jest-worker

除了我们的 index 文件之外,我们还需要一个单独的模块,它知道如何在工作进程中执行测试。让我们创建一个新文件 worker.js

// worker.js
const fs = require("fs");

exports.runTest = async function (testFile) {
  const code = await fs.promises.readFile(testFile, "utf8");

  return testFile + ":\n" + code;
};
// index.mjs
import { runTest } from './worker.js';

await Promise.all(
  Array.from(testFiles).map(async (testFile) => {
    console.log(await runTest(testFile));
  }),
);

但这还没有实现任何并行操作。我们需要在 index 文件和 worker 文件之间建立连接:

// index.mjs
import { Worker } from 'jest-worker';
import { join } from 'path';

const worker = new Worker(join(root, 'worker.js'), {
    enableWorkerThreads: true,
});

await Promise.all(
  Array.from(testFiles).map(async (testFile) => {
    // console.log(await runTest(testFile));
    const testResult = await worker.runTest(testFile);
    console.log(testResult);
  }),
);

worker.end(); // Shut down the worker.

这段代码通过 new Worker 创建了一个新的 Worker 实例,用于启动一个 worker.js 文件中的工作进程。这里有两个主要部分:

  1. join(root, 'worker.js')join 函数来自 path 模块,用于将 root 和 'worker.js' 这两个路径片段连接成一个完整的路径。这样,Worker 构造函数就知道在哪里找到 worker.js 文件。
  2. { enableWorkerThreads: true }:这是一个配置对象,传递给 Worker 构造函数。enableWorkerThreads 设置为 true 表示启用 Worker Threads 功能。Worker ThreadsNode.js 中的一个功能,允许在同一个进程中创建多个线程来并行执行任务。这对于充分利用多核 CPU 和提高应用性能非常有用。
// worker.js
exports.runTest = async function (testFile) {
  const code = await fs.promises.readFile(testFile, "utf8");
  return `worker id: ${process.env.JEST_WORKER_ID}\nfile: ${testFile}:\n${code}`;
};

worker.js 中,我们返回一个字符串。这个字符串包含三部分:

  • workerID(从环境变量 process.env.JEST_WORKER_ID 获取)
  • 测试文件的路径(testFile 变量)
  • 文件的内容(code 变量)

添加断言

到目前为止,我们已经能够读取到所有的测试文件里面的内容了,接下来就是进行断言,其中有一点就是需要执行测试文件里面的代码,这里我们选择使用 eval 来执行。

// worker.js
exports.runTest = async function (testFile) {
  const code = await fs.promises.readFile(testFile, "utf8");
  const testResult = {
    success: false,
    errorMessage: null,
  };
  try {
    eval(code);
    testResult.success = true;
  } catch (error) {
    testResult.errorMessage = error.message;
  }
  return testResult;
};

我们对 runTest 做了一些修改,之前仅仅是读取测试文件里面的内容,现在我们通过 eval 进行执行,并且定义了一个 testResult 的对象,用于向外部返回执行的结果。

但是目前执行所有的测试文件,都会遇到错误:expect is not defined,如果我们添加 expect 方法的逻辑:

const expect = (received) => ({
    toBe: (expected) => {
      if (received !== expected) {
        throw new Error(`Expected ${expected} but received ${received}.`);
      }
      return true;
    },
});
try {
    eval(code);
    testResult.success = true;
} catch (error) {
    testResult.errorMessage = error.message;
}
return testResult;

在上面的代码中,我们增加了一个 expect 方法,该方法返回一个对象,对象里面有一个 toBe 方法用于评判 receivedexpected 是否全等。

现在我们的测试框架已经能够正常运作了,运行 node index.mjs 的结果如下:

{ success: false, errorMessage: 'Expected 4 but received 3.' }
{ success: false, errorMessage: 'Expected 2 but received 1.' }
{ success: true, errorMessage: null }
{ success: true, errorMessage: null }
{ success: true, errorMessage: null }
{ success: false, errorMessage: 'Expected 6 but received 5.' }

但是这看上去不是太友好,我们需要美化一下控制台的输出,这里可以通过 chalk 这个库来做美化

npm i chalk
// index.mjs
import { join, relative } from 'path';
import chalk from 'chalk';

await Promise.all(
  Array.from(testFiles).map(async (testFile) => {
    const { success, errorMessage } = await worker.runTest(testFile);
    const status = success
      ? chalk.green.inverse.bold(" PASS ")
      : chalk.red.inverse.bold(" FAIL ");

    console.log(status + " " + chalk.dim(relative(root, testFile)));
    if (!success) {
      console.log("  " + errorMessage);
    }
  })
);

在上面的代码中,我们从 worker.runTest 方法中解构出测试文件的执行结果,然后根据测试结果(成功或失败),使用 chalk 库为控制台输出添加颜色和样式。

另外,目前 expect 是我们手动实现的,实际上我们可以使用 jest 提供的 expect 扩展库:

npm i expect
// worker.js
const { expect } = require("expect");

worker.js 中引入 expect 之后,就可以去除掉我们自己实现的 expect 了。

另外我们的测试框架目前还不支持 mock 功能,这个也可以通过 jest 提供的扩展库 jest-mock 来搞定:

npm i jest-mock
// worker.js
const mock = require('jest-mock');
// mock.test.js
const fn = mock.fn();

expect(fn).not.toHaveBeenCalled();

fn();
expect(fn).toHaveBeenCalled();

为了让我们的测试框架支持单独测试某一个测试文件,可以在获取所有测试文件的时候,从命令行参数(process.argv)中获取一个可选的文件名模式,然后使用 hasteFS.matchFilesWithGlob 函数匹配满足该模式的测试文件,如下:

// index.mjs
const testFiles = hasteFS.matchFilesWithGlob([
  process.argv[2] ? `**/${process.argv[2]}*` : "**/*.test.js",
]);

这样我们就可以在命令行中传入第二个参数,用于指定要执行的测试文件:

node index.mjs mock.test.js

另外在测试的时候,如果有失败的测试用例,我们也稍作修饰,给予用户更好的提示:

let hasFailed = false; // 是否有失败
await Promise.all(
  Array.from(testFiles).map(async (testFile) => {
    const { success, errorMessage } = await worker.runTest(testFile);
    const status = success
      ? chalk.green.inverse.bold(" PASS ")
      : chalk.red.inverse.bold(" FAIL ");

    console.log(status + " " + chalk.dim(relative(root, testFile)));
    if (!success) {
      hasFailed = true; // 有失败
      console.log("  " + errorMessage);
    }
  })
);

worker.end(); // Shut down the worker.
// 给予失败的信息展示
if (hasFailed) {
  console.log(
    "\n" + chalk.red.bold("Test run failed, please fix all the failing tests.")
  );
  // Set an exit code to indicate failure.
  process.exitCode = 1;
}

到目前为止,我们的测试框架已经初具规模,但是还有一些很常用的方法目前我们还不支持,例如 describe 以及 it,修改我们的 worker.js,添加这一部分的逻辑:

// worker.js
exports.runTest = async function (testFile) {
  const code = await fs.promises.readFile(testFile, "utf8");
  const testResult = {
    success: false,
    errorMessage: null,
  };

  try {
    // 定义一个数组 describeFns,用于存储所有的 describe 函数及其相关信息
    const describeFns = [];
    // 定义一个变量 currentDescribeFn,用于存储当前正在处理的 describe 函数的信息
    let currentDescribeFn;
    // 外部每执行一次 describe,就会将 describe 对应的回调推入到 describeFns 里面
    const describe = (name, fn) => describeFns.push([name, fn]);
    // 外部每执行一次 it,就会将 it 对应的回调推入到 currentDescribeFn 里面
    const it = (name, fn) => currentDescribeFn.push([name, fn]);

    eval(code);

    // 当执行完 eval 之后,就说明外部的 describe 已经执行,describe 内部的测试用例已经被推入到 describeFns
    // 接下来开始验证 describeFns 内部的所有测试用例
    for (const [name, fn] of describeFns) {
      currentDescribeFn = [];
      testName = name;
      fn();

      currentDescribeFn.forEach(([name, fn]) => {
        testName += " " + name;
        fn();
      });
    }

    testResult.success = true;
  } catch (error) {
    testResult.errorMessage = error.message;
  }
  return testResult;
};
// circus.test.js
describe("circus test", () => {
  it("works", () => {
    expect(1).toBe(1);
  });
});

describe("second circus test", () => {
  it(`doesn't work`, () => {
    expect(1).toBe(2);
  });
});

当然,我们也可以选择不手动实现,直接使用 jest 为我们提供的第三方库 jest-circus

npm i jest-circus
// worker.js
const { describe, it, run } = require('jest-circus');

exports.runTest = async function (testFile) {
  const code = await fs.promises.readFile(testFile, "utf8");
  const testResult = {
    success: false,
    errorMessage: null,
  };

  try {
    eval(code);

    const { testResults } = await run();
    testResult.testResults = testResults;
    testResult.success = testResults.every((result) => !result.errors.length);

  } catch (error) {
    testResult.errorMessage = error.message;
  }
  return testResult;
};

这里我们将 testResults 也一并返回给调用处,在调用处就可以拿到这个 testResults 数组,为错误信息显示更加完整的提示:

// index.mjs
await Promise.all(
  Array.from(testFiles).map(async (testFile) => {
    // 解构出 testResults
    const { success, testResults, errorMessage } = await worker.runTest(
      testFile,
    );
    const status = success
      ? chalk.green.inverse.bold(' PASS ')
      : chalk.red.inverse.bold(' FAIL ');

    console.log(status + ' ' + chalk.dim(relative(root, testFile)));
    if (!success) {
      hasFailed = true;
      // Make use of the rich `testResults` and error messages.
      // 失败了,如果 testResults 里面有值,则根据 testResults 显示更完整的信息
      if (testResults) {
        testResults
          .filter((result) => result.errors.length)
          .forEach((result) =>
            console.log(
              // Skip the first part of the path which is an internal token.
              result.testPath.slice(1).join(' ') + '\n' + result.errors[0],
            ),
          );
        // If the test crashed before `jest-circus` ran, report it here.
      } else if (errorMessage) {
        console.log('  ' + errorMessage);
      }
    }
  }),
);

如果你运行了很多测试,你会发现 jest-circus 没有自动重置状态,导致测试文件之间的状态共享。这并不是一个好的情况,我们可以通过使用 jest-circus 提供的 resetState 函数,在执行测试代码之前重置状态,来解决这个问题。

// worker.js
const { describe, it, run, resetState } = require('jest-circus');
// worker.js
try {
  resetState();
  eval(code);
  const { testResults } = await run();
  // […]
} catch (error) {
  /* […] */
}

至此,我们在不到 100 行的代码中,已经实现了一个测试框架的一些基本功能功能。

总结

本小节主要带着大家从零搭建一个测试框架。

如果你查看 Jest 的代码,你会注意到它由 50 个包组成。对于我们的基础测试框架,我们利用到了其中的一些。通过使用这些 Jest 的包,我们既可以了解测试框架的架构,也可以学习如何将这些包用于其他目的。


-EOF-

posted @   Zhentiw  阅读(4)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
历史上的今天:
2023-02-03 [Typescript] Defining exclusive properties with TypeScript
2021-02-03 [Docker] Run Stateless Docker Containers (Volumes with docker compose)
2021-02-03 [Javascript] Link to Other Objects through the JavaScript Prototype Chain (Object.setPrototypeOf())
2021-02-03 [Docker] Ensure Containers Run with High-Availability
2018-02-03 [Angular + TsLint] Disable directive selector tslint error
2018-02-03 [Angular] Create a ng-true-value and ng-false-value in Angular by controlValueAccessor
2018-02-03 [AngularJS] ng-ture-value & ng-false-value
点击右上角即可分享
微信分享提示