手写简易版测试框架
本小节,我将带着大家一些手写一个简易版的测试框架,部分模块为了方便,我们会直接使用 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 文件中的工作进程。这里有两个主要部分:
- 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) => {// 解构出 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-