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

news/2025/2/3 21:00:50/文章来源:https://www.cnblogs.com/Answer1215/p/18697728

手写简易版测试框架

本小节,我将带着大家一些手写一个简易版的测试框架,部分模块为了方便,我们会直接使用 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, // 并行处理所有可用的 CPUname: '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) => {// 解构出 testResultsconst { 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-

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/878386.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【前端】常用VsCode插件

根据插件的功能和用途,可以将这些 VSCode 插件分为以下几类,并进行排序: 1. 代码编辑与格式化Prettier - Code formatter自动格式化代码,支持多种语言。Prettier ESLint集成 Prettier 和 ESLint,统一代码风格。ESLint提供 JavaScript 和 TypeScript 的代码质量检查。Style…

4G与5G切换对比

本文来自博客园,作者:{IceSparks},转载请注明原文链接:https://www.cnblogs.com/IceSparks/p/18697730

越区覆盖解决思路

本文来自博客园,作者:{IceSparks},转载请注明原文链接:https://www.cnblogs.com/IceSparks/p/18697722

重叠覆盖解决思路

本文来自博客园,作者:{IceSparks},转载请注明原文链接:https://www.cnblogs.com/IceSparks/p/18697724

4G华为网管常用命令

本文来自博客园,作者:{IceSparks},转载请注明原文链接:https://www.cnblogs.com/IceSparks/p/18697717

5G华为网管常用命令

本文来自博客园,作者:{IceSparks},转载请注明原文链接:https://www.cnblogs.com/IceSparks/p/18697703

阿里云2025年2月免费领取300元无门槛优惠券!

详情免责声明 版权声明 交流群 公众hao服务器有什么用 服务器可以用于管理网络资源,比如控制网络访问、发送/接收电子邮件和 托管网站。服务器用于网站和大型数据库等应用,具有高速计算能力、长期可靠运行、强大的数据吞吐量、高可用性、可靠性、可扩展性和可管理性。24小时不…

win11中本地组策略编辑器(gpedit.msc)打不开解决方案

1,有内容需要用到本地组策略编辑器,结果发现竟然打不开了。后来百度了一下组策略的位置,去找了下果然没有。(下图是解决了问题的截图,没有选中那个文件)2,新建一个TXT,复制以下内容 @echo off pushd “%~dp0” dir /b C:\Windows\servicing\Packages\Microsoft-Windows…

PyCharm接入本地DeepSeek R1实现AI编程

大家好,我是六哥,欢迎来到今天的技术分享!今天我要给大家带来一个超实用的教程,教你如何使用PyCharm接入DeepSeek R1实现AI编程。就算你是编程小白,也能轻松搞定,话不多说,让我们开始吧! 一、为什么要在本地搭建DeepSeek R1模型? 在开始搭建之前,先和大家聊聊这样做的…

LM Studio 0.3.9 无须修改文件,无须魔法,就能下载 hugging face中的模型

LM Studio 无须魔法,无须修改文件,就可以下载 hugging face 中的模型。LM Studio之前版本需要修改配置文件中的下载路径实现从魔塔下载,新版本0.3.9已经可以使用代理下载了。 LM Studio 0.3.9 版本 中的设置中,已经加入 hugging face proxy代码功能,下载速度挺快。设置如下…

2025年这些实用的C#/.NET知识点你都知道吗?

前言 在这个快速发展的技术世界中,时常会有一些重要的知识点、信息或细节被忽略或遗漏。《C#/.NET/.NET Core拾遗补漏》专栏我们将探讨一些可能被忽略或遗漏的重要知识点、信息或细节,以帮助大家更全面地了解这些技术栈的特性和发展方向。✍C#/.NET/.NET Core拾遗补漏合集:h…