【译】手把手教你写单元测试框架(jest)

618 阅读13分钟

译自《building-a-javascript-testing-framework》

如果仔细看过 Jest 项目,就会发现它包含了 50 个package 。我们将利用其中的一部分来完成我们自己的基础测试框架。通过使用 Jest 的包,我们既能了解测试框架的体系结构,也能将其用于其他目的。如果你想进一步了解 Jest 的架构,强烈建议你看看这个 Jest 架构视频。

首先,我们给要写的框架起个名字,就叫做 best。让我们将测试框架的功能分解为多个步骤:

  1. 在文件系统上高效搜索测试文件
  2. 并行运行所有测试
  3. 使用断言框架编写测试和报告结果
  4. 隔离测试环境

我们需要Node.js 14 +,因为我们将使用ES模块.mjs文件。让我们开始初始化一个空项目并生成一些测试文件:

# In your terminal:
mkdir best-test-framework
cd best-test-framework
yarn init --yes
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
yarn add glob jest-haste-map

在文件系统上高效搜索测试文件

我们的首要任务是在我们的项目中找到所有相关的测试文件。如你所见,我们刚刚安装了两个JavaScript包来处理此问题。如果您曾经使用 node.js 编写工具,这将看起来很熟悉:

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

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

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

如果您运行 node index.mjs, 它会 log 出我们项目中的测试文件。太好了!Jest本身使用一个名为jest-haste-map的包。 可以分析项目并检索其中的文件列表。让我们用另一个方法从头开始:

// index.mjs
import JestHasteMap from 'jest-haste-map';
import { cpus } from 'os';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

// Get the root path to our project (Like `__dirname`).
const root = dirname(fileURLToPath(import.meta.url));
const hasteMapOptions = {
  extensions: ['js'],
  maxWorkers: cpus().length,
  name: 'best-test-framework',
  platforms: [],
  rootDir: root,
  roots: [root],
};
// Need to use `.default` as of Jest 27.
const hasteMap = new JestHasteMap.default(hasteMapOptions);
// This line is only necessary in `jest-haste-map` version 28 or later.
await hasteMap.setupCachePath(hasteMapOptions);

const { hasteFS } = await hasteMap.build();
const testFiles = hasteFS.getAllFiles();

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

是的,所以我们只是写了更多的代码来做和以前完全一样的事情。为什么? 虽然我们可能不需要 jest-haste-map, 但这是一个有用包,可用于分析和操作大型项目:

  • 抓取整个项目,提取依赖项,并跨工作进程并行分析文件。
  • 将文件系统的缓存保存在内存和磁盘上,以便快速执行与文件相关的操作。
  • 仅在文件更改时做最少的必要工作。
  • 观察文件系统的变化,这对构建交互式工具很有用。

如果你想要学习更多的相关内容,可以查看这个行内注释和尝试 jest-haste-mapoptions。让我们回到之前的代码示例:

// index.mjs
const hasteMapOptions = {
  extensions: ['js'], // Tells jest-haste-map to only crawl .js files.
  maxWorkers: cpus().length, // Parallelizes across all available CPUs.
  name: 'best-test-framework', // Used for caching.
  platforms: [], // This is only used for React Native, leave empty.
  rootDir: root, // The project root.
  roots: [root], // Can be used to only search a subset of files within `rootDir`.
};
// Need to use `.default` as of Jest 27.
const hasteMap = new JestHasteMap.default(hasteMapOptions);
// This line is only necessary in `jest-haste-map` version 28 or later.
await hasteMap.setupCachePath(hasteMapOptions);
// Build and return an in-memory HasteFS ("Haste File System") instance.
const { hasteFS } = await hasteMap.build();

我们只需要解决一件事: 我们建立了一个 js 的虚拟文件系统,我们需要筛选出需要的 .test.js 文件。这就是 jest-haste-map 的好用之处,我们可以将 globs 应用于缓存在内存的文件系统,而不是操作真实的文件:

// index.mjs
const testFiles = hasteFS.matchFilesWithGlob(['**/*.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);
  }),
);

这将打印所有文件及其内容,甚至使工作并行化。并行化?嗯,不完全是。它使用 async/await ,但在 JavaScript 中,所有内容都在单个线程中运行。这意味着,如果我们在同一个循环中运行测试,它们就不会同时运行。如果要构建快速测试框架,则需要使用所有可用的cpu。Node.js 最近增加了对工作线程的支持,允许在同一进程中跨线程并行工作。 它需要一些样板文件,因此我们将使用jest-worker,运行yarn add 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 里原来的循环:

// index.mjs
import { runTest } from './worker.js';

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

但这还不能并行化任何东西。我们需要在索引和工作文件之间建立连接:

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

const worker = new Worker(join(root, 'worker.js'));

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

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

如果你开始运行,best 将创建多个进程并在 worker.js 同时使用所有可用的cpu。您可以把将 runTest 的返回值改成下面这样来验证这一点,它将为每个工作进程打印不同的id:

// 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}`;
};

但是,我们只是在谈论工作线程并且jest-worker正在创建进程,而不是在不同的线程中运行代码。让我们启用工作线程:

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

太棒了,只有大约30行代码,我们构建了一个程序,可以查找所有测试文件并并行处理它们。因此,我们完成了第二项要求:✅并行运行所有测试。这是到目前为止我们编写的所有代码:

// index.mjs
import JestHasteMap from 'jest-haste-map';
import { cpus } from 'os';
import { dirname } from 'path'; 
import { fileURLToPath } from 'url';
import fs from 'fs';
import { Worker } from 'jest-worker';
import { join } from 'path';

// Get the root path to our project (Like `__dirname`).
const root = dirname(fileURLToPath(import.meta.url));
const hasteMapOptions = {
  extensions: ['js'],
  maxWorkers: cpus().length,
  name: 'best-test-framework',
  platforms: [],
  rootDir: root,
  roots: [root],
};
// Need to use `.default` as of Jest 27.
const hasteMap = new JestHasteMap.default(hasteMapOptions);
// This line is only necessary in `jest-haste-map` version 28 or later.
await hasteMap.setupCachePath(hasteMapOptions);
const { hasteFS } = await hasteMap.build();
const testFiles = hasteFS.matchFilesWithGlob(['**/*.test.js']);

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

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

worker.end();
// worker.js
const fs = require('fs');

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

  return testFile + ':\n' + code;
};
// package.json
{
  "name": "best-test-framework",
  "version": "1.0.0",
  "license": "MIT",
  "dependencies": {
    "jest-haste-map": "^28.1.1",
    "jest-worker": "^28.1.1"
  }
}

使用断言框架编写测试和报告结果

好吧,我们已经做到了,但是我们实际上并没有运行测试,我们只是构建了一个分布式文件阅读器。使用jest-haste-map在文件系统上分析文件,并且使用 jest-worker 在后台并行运行。在现代 SSD 上, 跨线程或进程并行读取文件比在单个进程中执行要快得多。多方便啊!

好吧,回到构建我们的测试运行程序 best. 我们实际上如何运行我们的测试?让我们使用 eval 开始:

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

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

当我们运行测试框架时,它将立即崩溃“ReferenceError: expect is not defined”。让我们在eval调用中添加一个守卫,这样单个失败的测试就不会破坏我们的整个测试框架:

// worker.js
try {
  eval(code);
} catch (error) {
  // Something went wrong.
}

运行的话,每个测试都会输出 undefined。现在是时候开始考虑将测试结果从工作进程报告给父进程了。让我们定义一下 testResult 的结构:

// 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;
};

有了这个,我们的测试框架将执行所有测试,并报告每个测试的成功或失败。是时候构建我们的断言框架了。 让我们通过一个基础匹配器 expect(…).toBe 开始:

// worker.js
const expect = (received) => ({
  toBe: (expected) => {
    if (received !== expected) {
      throw new Error(`Expected ${expected} but received ${received}.`);
    }
    return true;
  },
});
eval(code);

哇,就是这样:我们自定义的 expect 实现只有两个嵌套的函数。我们有一个快速并行化的测试框架,报告测试框架的成功或失败状态。这是有效的,因为eval可以访问它周围的作用域。你能在你的测试文件中添加新的断言,如toBeGreaterThan,toContainstringContaining并使用它们吗?

处理完断言以后,让我们花些时间来完善best的输出。这是运行 node index.mjs时的输出:

// node index.mjs
{"success": true, "errorMessage": null}
{"success": true, "errorMessage": null}
{"success": false, "errorMessage": "Expected 6 but received 5."}
{"success": false, "errorMessage": "Expected 2 but received 1."}
{"success": false, "errorMessage": "Expected 4 but received 3."}
{"success": true, "errorMessage": null}

我们无法真正看到哪些测试成功,哪些测试失败。我认为是时候添加一些标志性的 jest 输出了。运行yarn add chalk 并修改我们的测试循环并重新运行所有测试:

// index.mjs
import chalk from 'chalk';
import { relative } from 'path';

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);
    }
  }),
);

我们做到了,我们建造了Jest… ehh best! 但是我们要超越自己,我们仍然可以做一些事情来改善我们的断言。运行 yarn add expect, 这是 Jest 框架的另一个 package, 改变我们的worker.js文件如下所示:

// worker.js
const fs = require('fs');
const expect = require('expect').default;

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;
};

当我们再次执行测试框架时,我们现在使用的代码与Jest用于断言的代码完全相同。还不相信?运行yarn add jest-mock,添加const mock = require('jest-mock')worker.js 并创建一个新的测试文件,如下所示:

// tests/mock.test.js
const fn = mock.fn();

expect(fn).not.toHaveBeenCalled();

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

运行 node index.mjs, jest-haste-map将识别新添加的文件,执行我们的测试,并报告通过。我们现在有七个测试,在输出中识别我们关心的测试变得有点困难。让我们通过命令行快速添加测试过滤:

// in index.mjs, change `const testFiles = …;` with this:
const testFiles = hasteFS.matchFilesWithGlob([
  process.argv[2] ? `**/${process.argv[2]}*` : '**/*.test.js',
]);

闲杂你可以运行 node index.mjs mock.test.js ,它将只运行与参数匹配的测试。当我们正确打印测试结果时,我们没有使用正确的故障代码退出流程. 让我们跟踪测试是否失败并设置process.exitCode以防出现问题:

// index.mjs
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; // Something went wrong!
      console.log('  ' + errorMessage);
    }
  }),
);
worker.end();
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;
}

✅ 第三步,通过两个 Jest 包expectjest-mock完成测试编写和结果报告。在我们继续之前,有件事困扰着我,你可能已经注意到:我们的测试中只有断言,但通常测试框架有分组方法,如describe and it. 创建以下文件:

// tests/circus.test.js
describe('circus test', () => {
  it('works', () => {
    expect(1).toBe(1);
  });
});

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

让我们扩展我们的测试框架,以便它可以处理这些分组,并在发生故障时打印完整的测试名称。我们现在在技术上将测试的定义与实际执行分开。让我们更新我们的测试运行程序,以包括describe and it的实现:

// worker.js
try {
  const describeFns = [];
  let currentDescribeFn;
  const describe = (name, fn) => describeFns.push([name, fn]);
  const it = (name, fn) => currentDescribeFn.push([name, fn]);
  eval(code);

  testResult.success = true;
} catch (error) {
  // …
}

当我们使用 node index.mjs circus 时,会错误的标识测试通过了。因为我们没有运行任何断言,我们只是收集了 describe 中的内容。我们需要真正运行 describeit 中的函数:

// worker.js
let testName; // Use this variable to keep track of the current test.
try {
  const describeFns = [];
  let currentDescribeFn;
  const describe = (name, fn) => describeFns.push([name, fn]);
  const it = (name, fn) => currentDescribeFn.push([name, fn]);
  eval(code);
  for (const [name, fn] of describeFns) {
    currentDescribeFn = [];
    testName = name;
    fn();

    currentDescribeFn.forEach(([name, fn]) => {
      testName += ' ' + name;
      fn();
    });
  }
  testResult.success = true;
} catch (error) {
  testResult.errorMessage = testName + ': ' + error.message;
}

现在,我们通过运行 node index.mjs circus 正确报告了失败的测试的名称。我们的测试框架将 log second circus test doesn't work: expect(received).toBe(expected). 整洁!

一个完整的测试运行有能力嵌套 describe 的调用,允许异步执行测试,管理超时,报告状态,并可以跳过禁用的测试。你知道接下来会发生什么,对吧?没错,有一个包叫做 jest-circus 我们可以整合来解决这个问题。运行yarn add jest-circus ,让我们重构我们的 workder.js:

// worker.js
const fs = require('fs');
const expect = require('expect');
const mock = require('jest-mock');
// Provide `describe` and `it` to tests.
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);
    // Run jest-circus.
    const { testResults } = await run();
    testResult.testResults = testResults;
    testResult.success = testResults.every((result) => !result.errors.length);
  } catch (error) {
    testResult.errorMessage = error.message;
  }
  return testResult;
};

在接收方,让我们利用新添加的信息来打印完整的测试名称:

// index.mjs
await Promise.all(
  Array.from(testFiles).map(async (testFile) => {
    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.
      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-circus ,我们遇到了这样的情况,我们可能最终在两个测试之间共享状态。我们最多只能分配 cpu 数量的进程/线程。如果我们运行更多的测试,jest-worker 会让不同的测试文件重用同一个 worker 。同时,我们通过 eval 执行代码,这意味着测试中的任何代码都可能泄漏并影响其他测试文件。你可以尝试这样验证,在测试中将方法附加到 Array.prototype,另一个测试将将能够使用它。

幸运的是,Node.js 有一个vm模块,可用于沙箱代码。我们可以使用这个模块将测试相互隔离,并且只向测试文件公开一部分功能:

// worker.js
const vm = require('vm');

// replace `eval(code);` with this:
const context = { describe, it, expect, mock };
vm.createContext(context);
vm.runInContext(code, context);

仅此而已,测试将不再能够相互影响!

让我们尝试编写一个异步测试:

// tests/circus.test.js
describe('second circus test', () => {
  it(`doesn't work`, async () => {
    await new Promise((resolve) => setTimeout(resolve, 2000));
    expect(1).toBe(2);
  });
});

该测试将失败并报告setTimeout未定义。这是因为该context变量充当 vm 内部的全局变量, 这就意味着沙盒上下文无法访问setTimeoutbuffer以及仅在我们的主上下文中定义的各种其他Node.js功能。 Jest使用jest-environment-node包为测试提供类似Node.js的环境。运行yarn add jest-environment-node安装:

// replace this code in worker.js:
const context = { describe, it, expect, mock };
vm.createContext(context);

// with this:
const NodeEnvironment = require('jest-environment-node');
const environment = new NodeEnvironment({
  projectConfig: {
    testEnvironmentOptions: { describe, it, expect, mock },
  },
});
vm.runInContext(code, environment.getVmContext());

最后,有一件事我们还没有机会考虑: 如果在我们的测试中需要其他文件。以前我们使用eval,这意味着每个导入都是相对于我们的工作文件而不是测试文件。现在我们正在使用一个vm上下文,它甚至没有 require 实现。一个完整的实现可能会变得相当复杂,但让我们尝试近似一个。首先,我们需要更新我们的测试文件:

// tests/circus.test.js
const banana = require('./banana.js');

it('tastes good', () => {
  expect(banana).toBe('good');
});
// tests/banana.js
module.exports = 'good';

如果我们按原样运行代码,我们将得到报错“ require is not defined”。我们需要做的是在我们的沙箱中提供一个自定义的 require 实现,它可以识别正确的文件来加载和评估它。当我们使用时,eval我们可以通过在执行代码之前定义变量来做到这一点。现在我们需要实际修改我们正在运行的代码来定义局部变量。第一种方法可能如下所示:

// worker.js
let environment;
const { dirname, join } = require('path');
const customRequire = (fileName) => {
  const code = fs.readFileSync(join(dirname(testFile), fileName), 'utf8');
  return vm.runInContext(
    // Define a module variable, run the code and "return" the exports object.
    'const module = {exports: {}};\n' + code + ';module.exports;',
    environment.getVmContext(),
  );
};
environment = new NodeEnvironment({
  projectConfig: {
    testEnvironmentOptions: {
      describe,
      it,
      expect,
      mock,
      // Add the custom require implementation as a global function.
      require: customRequire,
    },
  },
});

它起作用了!我们的测试通过了,但是如果我们想在 banana.jsrequire 文件,或者想引用其他模块呢?

// tests/apple.js
module.exports = 'delicious';

// tests/circus.test.js
const apple = require('./apple.js');

it('tastes delicious', () => {
  expect(apple).toBe('delicious');
});

我们会出错 Identifier 'module' has already been declared. 看起来我们仍然在多个文件中共享模块代码。我们需要修改我们的方法,以便在上下文中将各个模块彼此隔离。我们通常如何确保变量定义仅对代码的一部分有效?没错: 我们将代码分成函数!这很棘手,因为我们需要以某种方式在父级和沙箱上下文之间共享信息,而不依赖于全局范围。如果你仔细看上面的示例,就会发现vm.runInContext实际上将最后一条语句返回给父上下文,有点像隐含的return。让我们利用这点,用这个函数包装器替换我们的 customRequire

// worker.js
const customRequire = (fileName) => {
  const code = fs.readFileSync(join(dirname(testFile), fileName), 'utf8');
  // Define a function in the `vm` context and return it.
  const moduleFactory = vm.runInContext(
    `(function(module) {${code}})`,
    environment.getVmContext(),
  );
  const module = { exports: {} };
  // Run the sandboxed function with our module object.
  moduleFactory(module);
  return module.exports;
};

完美,我们现在可以 require 多个模块,并且每个模块都有各自的模块作用域。为了提供合适的模块和 require 实现,我们需要本地状态和自定义的 require 实现,用于我们想要在框架中执行的每个文件。我们还需要处理node resolution algorithm的全部实现。让我们移除 require 在全局作用域的实现,使用我们的 customRequire 在模块级别注入,以便能够及时清除。我们的最终版本worker.js如下所示:

// worker.js
const fs = require('fs');
const expect = require('expect');
const mock = require('jest-mock');
const { describe, it, run, resetState } = require('jest-circus');
const vm = require('vm');
const NodeEnvironment = require('jest-environment-node');
const { dirname, basename, join } = require('path');

exports.runTest = async function (testFile) {
  const testResult = {
    success: false,
    errorMessage: null,
  };
  try {
    resetState();
    let environment;
    const customRequire = (fileName) => {
      const code = fs.readFileSync(join(dirname(testFile), fileName), 'utf8');
      const moduleFactory = vm.runInContext(
        // Inject require as a variable here.
        `(function(module, require) {${code}})`,
        environment.getVmContext(),
      );
      const module = { exports: {} };
      // And pass customRequire into our moduleFactory.
      moduleFactory(module, customRequire);
      return module.exports;
    };
    environment = new NodeEnvironment({
      projectConfig: {
        testEnvironmentOptions: {
          describe,
          it,
          expect,
          mock,
        },
      },
    });
    // Use `customRequire` to run the test file.
    customRequire(basename(testFile));
    const { testResults } = await run();
    testResult.testResults = testResults;
    testResult.success = testResults.every((result) => !result.errors.length);
  } catch (error) {
    testResult.errorMessage = error.message;
  }
  return testResult;
};

一个真正的测试框架必须提供一个真正的 require 实现,并将像TypeScript这样的文件编译成在Node.js中运行的东西。Jest有一整套包来处理这些要求,例如 jest-runtime, jest-resolvejest-transform。您现在应该拥有将这些东西拼凑在一起并构建一个很棒的测试框架所需的所有知识!如果您已经做到了这一点,请考虑将以下功能添加到您的测试框架中:

  • 打印出每个 describe/be 的 name,包括通过的测试。
  • 打印有多少测试和断言通过或失败的摘要,以及测试运行所用的时间。
  • 确认每个文件中至少有一个使用 it 的测试,如果没有任何断言,则将测试标为失败。
  • 可以通过 require('best')加载 escribe, it, expect 和 mock
  • 中级: 在执行之前使用Babel或TypeScript转换测试代码。
  • 中级: 添加配置文件和命令行标志以自定义测试运行,例如改变输出颜色,限制工作进程的数量或者 bail 配置一个测试失败时立即退出的选项。
  • 中级: 添加用于记录和比较快照的功能。
  • 高级: 添加一个 watch 模式,在测试发生变化时重新运行测试,使用jest-haste-mapwatch 选项并运行 hasteMap.on('change', (changeEvent) => { … })来监听。
  • 高级: 利用jest-runtimejest-resolve提供完整的模块和require实现。
  • *高级:*收集代码覆盖范围需要什么?我们如何转换测试代码以跟踪运行了哪些代码行?

我们只用了 100 行代码,就构建了一个类似于 Jest 的强大测试框架。您可以在 GitHub 上找到测试框架best的完整实现。虽然我们添加了许多测试框架中常见的特性,但要将其转化为可行的测试框架还需要做更多的工作:

  • 测序测试以优化性能
  • 编译JavaScript或TypeScript文件
  • 收集代码覆盖率
  • 互动 watch 模式
  • 快照测试
  • 在一次测试运行中运行多个项目
  • 实时报告和打印测试运行总结
  • 等等…

如您所见,Jest不仅仅是一个测试框架,它还是一个通过其50 个包构建测试框架的框架。在下一篇文章中,我们将使用我们的新知识来构建一个 JavaScript 捆绑器。