[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 文件中的工作进程。这里有两个主要部分:
- join(root, 'worker.js'):join 函数来自 path 模块,用于将 root 和 'worker.js' 这两个路径片段连接成一个完整的路径。这样,Worker 构造函数就知道在哪里找到 worker.js 文件。
- { enableWorkerThreads: true }:这是一个配置对象,传递给 Worker 构造函数。enableWorkerThreads 设置为 true 表示启用 Worker Threads 功能。Worker Threads 是 Node.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 中,我们返回一个字符串。这个字符串包含三部分:
- worker 的 ID(从环境变量 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 方法用于评判 received 和 expected 是否全等。
现在我们的测试框架已经能够正常运作了,运行 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-
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源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