【彻底搞懂 Jest 单元测试框架】读后感

1,496 阅读9分钟

万字详文:彻底搞懂 Jest 单元测试框架

测试概述:

我们经常说的单元测试其实只是前端测试的一种。前端测试分为单元测试,UI 测试,集成测试和端到端测试。

  • 单元测试:是指对软件中的最小可测试单元进行检查和验证,通常指的是独立测试单个函数。
  • UI 测试:是对图形交互界面的测试。
  • 集成测试:就是测试应用中不同模块如何集成,如何一起工作,这和它的名字一致。
  • 端到端测试(e2e):是站在用户角度的测试,把我们的程序看成是一个黑盒子,我不懂你内部是怎么实现的,我只负责打开浏览器,把测试内容在页面上输入一遍,看是不是我想要得到的结果。

前端测试的框架可谓是百花齐放。

  • 单元测试有 Mocha, Ava, Karma, Jest, Jasmine 等。
  • UI 测试有 ReactTestUtils, Test Render, Enzyme, React-Testing-Library, Vue-Test-Utils 等。
  • e2e 测试有 Nightwatch, Cypress, Phantomjs, Puppeteer 等。

Pasted Graphic.png

每个框架的特点:

  • Mocha 是生态最好,使用最广泛的单测框架,但是他需要较多的配置来实现它的高扩展性。
  • Jasmine 是单测框架的“元老”,开箱即用,但是异步测试支持较弱。
  • Jest 基于 Jasmine, 做了大量修改并添加了很多特性,同样开箱即用,但异步测试支持良好。
  • Karma 能在真实的浏览器中测试,强大适配器,可配置其他单测框架,一般会配合 Mocha 或 Jasmine 等一起使用。

总结:

  • 每个框架都有自己的优缺点,没有最好的框架,只有最适合的框架。 Augular 的默认测试框架就是 Karma + Jasmine, 而 React 的默认测试框架是 Jest.
  • Jest 被各种 React 应用推荐和使用。它基于 Jasmine,至今已经做了大量修改并添加了很多特性,同样也是开箱即用,支持断言,仿真,快照等。Create React App 新建的项目就会默认配置 Jest,我们基本不用做太多改造,就可以直接使用。

单元测试最核心的部分就是做断言,有了断言库之后我们还需要使用测试框架将我们的断言更好地组织起来

自己如何开发一个"单元测试"功能

单元测试抽象

检查【函数】是否产生预期结果。最典型的测试流程如下所示:

  • 导入要测试的函数
  • 给函数一个输入
  • 定义期望的输出
  • 检查函数是否产生预期的输出 即:导入函数 + 传入输入 -> 预期输出 -> 断言结果

定义一个测试函数

// teset.spec.js
const sum = (a, b) => a + b;

编写测试块

test("sum test", () => {
  expect(sum(1, 2)).toBe(3);
});
test("sum test 1", () => {
  expect(sum(1, 2)).toBe(4);
});

我们观察上面代码有发现有两点:

  • test 块是单独的测试块,它拥有描述和划分范围的作用,即它代表我们要为该计算函数 sum 所编写测试的通用容器。
  • expect 是一个断言,该语句使用输入 1 和 2 调用被测函数中的 sum 方法,并期望输出 3。
  • toBe 是一个匹配器,用于检查期望值,如果不符合预期结果则应该抛出异常。

如何实现单元测试?

把expect全部断言整理到一起,然后批量测试,测试后生成报告,可以借鉴redux的思想,统一管理断言expect

1. 如何实现测试块test

const test = (name, fn) => {
  dispatch({ type: "ADD_TEST", fn, name });
};

我们需要在全局创建一个 state 保存测试的回调函数,测试的回调函数使用一个数组存起来。

global["STATE_SYMBOL"] = {
  testBlock: [],
};

dispatch 方法此时只需要甄别对应的命令,并把测试的回调函数存进全局的 state 即可。类似redux管理方式

const dispatch = (event) => {
  const { fn, type, name } = event;
  switch (type) {
    case "ADD_TEST":
      const { testBlock } = global["STATE_SYMBOL"];
      testBlock.push({ fn, name });
      break;
   case "RUN":
      testBlock.forEach(async (item) => { const { fn, name } = item; await fn.apply(this); });
      break;
    case "COLLECT_REPORT":
      break
  }
};

2. 如何实现断言和匹配器:expect(A).toBe(B)

const expect = (actual) => ({
    toBe(expected) {
        if (actual !== expected) {
            throw new Error(`${actual} is not equal to ${expected}`);
        }
    }
};

3. Mock:

在 Jest 文档中,我们可以找到 Jest 对模拟有以下描述:”模拟函数通过抹去函数的实际实现、捕获对函数的调用,以及在这些调用中传递的参数,使测试代码之间的链接变得容易“

第一种:

jest.mock("fs", {
  readFile: jest.fn(() => "wscats"),
});

如何实现 需要把具体的实现方法找一个地方存起来即可,等后续真正使用改模块的时候替换掉即可,所以我们把它存到 require.cache 里面,当然我们也可以存到全局的 state 中。

const jest = {
  mock(mockPath, mockExports = {}) {
    const path = require.resolve(mockPath, { paths: ["."] });
    require.cache[path] = {
      id: path,
      filename: path,
      loaded: true,
      exports: mockExports,
    };
  },
};

4. CLI 和配置

编写完测试之后,我们则需要在命令行中输入命令运行单测,正常情况下,命令类似如下:

node jest xxx.spec.js

这里本质是解析命令行的参数。

const testPath = process.argv.slice(2)[0];
const code = fs.readFileSync(path.join(process.cwd(), testPath)).toString();

复杂的情况可能还需要读取本地的 Jest 配置文件的参数来更改执行环境等,Jest 在这里使用了第三方库 yargs execa 和 chalk 等来解析执行并打印命令。

5. 执行环境

在测试框架中,我们并不需要手动引入 testexpect 和 jest 这些函数,每个测试文件可以直接使用,所以我们这里需要创造一个注入这些方法的运行环境。

6. 作用域隔离

由于单测文件运行时候需要作用域隔离。所以在设计上测试引擎是跑在 node 全局作用域下,而测试文件的代码则跑在 node 环境里的 vm 虚拟机局部作用域中。

  • 全局作用域 global
  • 局部作用域 context

两个作用域通过 dispatch 方法实现通信。 dispatch 在 vm 局部作用域下收集测试块、生命周期和测试报告信息到 node 全局作用域 STATE_SYMBOL 中,所以 dispatch 主要涉及到以下各种通信类型:

  • 测试块
    • ADD_TEST
  • 生命周期
    • BEFORE_EACH
    • BEFORE_ALL
    • AFTER_EACH
    • AFTER_ALL
  • 测试报告
    • COLLECT_REPORT

7. V8 虚拟机

既然万事俱备只欠东风,我们只需要给 V8 虚拟机注入测试所需的方法,即注入测试局部作用域即可。

const context = {
  console: console.Console({ stdout: process.stdout, stderr: process.stderr }),
  jest,
  expect,
  require,
  test: (name, fn) => dispatch({ type: "ADD_TEST", fn, name }),
};

注入完作用域,我们就可以让测试文件的代码在 V8 虚拟机中跑起来,这里我传入的代码是已经处理成字符串的代码,Jest 这里会在这里做一些代码加工,安全处理和 SourceMap 缝补等操作,我们示例就不需要搞那么复杂了。

vm.runInContext(code, context);

vm解释参考: Node.js VM 不完全指北 概述VM: vm 的功能是可以在 V8 虚拟机的上下文中编译和执行 JavaScript 代码。它比 evalFunction 更安全,而且同样很简单。运行第三方代码的唯一安全方法是“物理地”将应用程序与该代码分离,例如,通过虚拟机、docker、容器中运行它才是让你更放心的方案,但是如果是对于安全要求没有那么高的场景(eg非生产环境),vm 应该是一个简单有效的 runtime 方案。

在代码执行的前后可以使用时间差算出单测的运行时间,Jest 还会在这里预评估单测文件的大小数量等,决定是否启用 Worker 来优化执行速度

const start = new Date();
const end = new Date();
log("\x1b[32m%s\x1b[0m", `Time: ${end - start} ms`);

8. 运行单测回调

V8 虚拟机执行完毕之后,全局的 state 就会收集到测试块中所有包装好的测试回调函数,我们最后只需要把所有的这些回调函数遍历取出来,并执行。

testBlock.forEach(async (item) => {
  const { fn, name } = item;
  await fn.apply(this);
});

9. 钩子函数

我们还可以在单测执行过程中加入生命周期,例如 beforeEachafterEachafterAll 和 beforeAll 等钩子函数。

在上面的基础架构上增加钩子函数,其实就是在执行 test 的每个过程中注入对应回调函数,比如 beforeEach 就是放在 testBlock 遍历执行测试函数前,afterEach 就是放在 testBlock 遍历执行测试函数后,非常的简单,只需要位置放对就可以暴露任何时期的钩子函数。

testBlock.forEach(async (item) => {
  const { fn, name } = item;
  beforeEachBlock.forEach(async (beforeEach) => await beforeEach());
  await fn.apply(this);
  afterEachBlock.forEach(async (afterEach) => await afterEach());
});

而 beforeAll 和 afterAll 就可以放在,testBlock 所有测试运行完毕前和后。

beforeAllBlock.forEach(async (beforeAll) => await beforeAll());
testBlock.forEach(async (item) => {})
afterAllBlock.forEach(async (afterAll) => await afterAll());

10. 生成报告

当单测执行完后,可以收集成功和捕捉错误的信息集,

try {
    dispatch({ type: "COLLECT_REPORT", name, pass: 1 });
    log("\x1b[32m%s\x1b[0m", `√ ${name} passed`);
} catch (error) {
    dispatch({ type: "COLLECT_REPORT", name, pass: 0 });
    log("\x1b[32m%s\x1b[0m", `× ${name} error`);
}

然后劫持 log 的输出流,让详细的结果打印在终端上,也可以配合 IO 模块在本地生成报告。

const { reports } = global["STATE_SYMBOL"];
const pass = reports.reduce((pre, next) => pre.pass + next.pass);
log("\x1b[32m%s\x1b[0m", `All Tests: ${pass}/${reports.length} passed`);

综上,就实现了一个简单的 Jest 测试框架的核心部分,以上部分基本实现了测试块、断言、匹配器、CLI配置、函数模拟、使用虚拟机及作用域和生命周期钩子函数等,我们可以在此基础上,丰富断言方法,匹配器和支持参数配置

JEST如何实现的?

调试源码:

下载 Jest 源码,根目录下执行

yarn
npm run build

vscode: run->Add Configuration

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Jest Current File",
      "program": "${workspaceFolder}/node_modules/.bin/jest",
      "args": ["${fileBasenameNoExtension}"],
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen",
      "disableOptimisticBPs": true,
      "windows": {
        "program": "${workspaceFolder}/node_modules/jest/bin/jest"
      }
    }
  ]
}

源码Debug:

源码总结:

  1. 根据jest.config.js配置信息(即定义我们要测试什么,测试规则)和jest代码中的默认配置生成globalConfig和projectsConfig信息
  2. 获取可以测试的文件,整合配置信息生成执行上下文context。
  3. 如果是多核系统,启动多进程执行测试
  4. 启动vm进行环境隔离,将context转入vm中。
  5. 加载测试文件,注入声明周期,执行测试代码。
  6. 在测试过程中记录测试结果,生成测试报告。

脑图

配置信息参考:jestjs.io/docs/config… globalConfig

{
  bail: 0,
  changedFilesWithAncestor: false,
  changedSince: undefined,
  collectCoverage: false,
  collectCoverageFrom: [
    "**/packages/*/**/*.js",
    "**/packages/*/**/*.ts",
    "!**/bin/**",
    "!**/cli/**",
    "!**/perf/**",
    "!**/__mocks__/**",
    "!**/__tests__/**",
    "!**/build/**",
    "!**/vendor/**",
    "!e2e/**",
  ],
  collectCoverageOnlyFrom: undefined,
  coverageDirectory: "/Users/baidu/Documents/source/jestMaster/jest/coverage",
  coverageProvider: "babel",
  coverageReporters: [
    "json",
    "text",
    "lcov",
    "clover",
  ],
  coverageThreshold: undefined,
  detectLeaks: false,
  detectOpenHandles: false,
  errorOnDeprecated: false,
  expand: false,
  filter: undefined,
  findRelatedTests: false,
  forceExit: false,
  globalSetup: undefined,
  globalTeardown: undefined,
  json: false,
  lastCommit: false,
  listTests: false,
  logHeapUsage: false,
  maxConcurrency: 5,
  maxWorkers: 3,
  noSCM: undefined,
  noStackTrace: false,
  nonFlagArgs: [
    "index",
  ],
  notify: false,
  notifyMode: "failure-change",
  onlyChanged: false,
  onlyFailures: false,
  outputFile: undefined,
  passWithNoTests: false,
  // 重点:
  projects: [
    "/Users/baidu/Documents/source/jestMaster/jest",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/angular",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/async",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/automatic-mocks",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/enzyme",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/getting-started",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/jquery",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/manual-mocks",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/module-mock",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/mongodb",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/react",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/react-native",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/react-testing-library",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/snapshot",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/timer",
    "/Users/baidu/Documents/source/jestMaster/jest/examples/typescript",
  ],
  replname: undefined,
  reporters: undefined,
  rootDir: "/Users/baidu/Documents/source/jestMaster/jest",
  runTestsByPath: false,
  silent: undefined,
  skipFilter: false,
  snapshotFormat: undefined,
  testFailureExitCode: 1,
  testNamePattern: undefined,
  testPathPattern: "index",
  testResultsProcessor: undefined,
  testSequencer: "/Users/baidu/Documents/source/jestMaster/jest/packages/jest-test-sequencer/build/index.js",
  testTimeout: undefined,
  updateSnapshot: "new",
  useStderr: false,
  verbose: undefined,
  watch: false,
  watchAll: false,
  watchPlugins: [
    {
      config: {
      },
      path: "/Users/baidu/Documents/source/jestMaster/jest/node_modules/jest-watch-typeahead/filename.js",
    },
    {
      config: {
      },
      path: "/Users/baidu/Documents/source/jestMaster/jest/node_modules/jest-watch-typeahead/testname.js",
    },
  ],
  watchman: true,
}

projectConfig

// 
{
  automock: false,
  cache: true,
  cacheDirectory: "/private/var/folders/hj/99qskfl12hdcjw34wrzt5x5r0000gn/T/jest_dx",
  clearMocks: false,
  coveragePathIgnorePatterns: [
    "/node_modules/",
  ],
  cwd: "/Users/baidu/Documents/source/jestMaster/jest",
  dependencyExtractor: undefined,
  detectLeaks: false,
  detectOpenHandles: false,
  displayName: undefined,
  errorOnDeprecated: false,
  extensionsToTreatAsEsm: [
  ],
  extraGlobals: [
  ],
  filter: undefined,
  forceCoverageMatch: [
  ],
  globalSetup: undefined,
  globalTeardown: undefined,
  globals: {
  },
  haste: {
    computeSha1: false,
    enableSymlinks: false,
    forceNodeFilesystemAPI: false,
    throwOnModuleCollision: false,
  },
  injectGlobals: true,
  moduleDirectories: [
    "node_modules",
  ],
  moduleFileExtensions: [
    "js",
    "jsx",
    "ts",
    "tsx",
    "json",
    "node",
  ],
  moduleLoader: undefined,
  moduleNameMapper: [
  ],
  modulePathIgnorePatterns: [
    "examples/.*",
    "packages/.*/build",
    "packages/.*/tsconfig.*",
    "packages/jest-runtime/src/__tests__/test_root.*",
    "website/.*",
    "e2e/runtime-internal-module-registry/__mocks__",
  ],
  modulePaths: undefined,
  name: "f34c9cc6de3c4677db6de6222fb812a5",
  prettierPath: "prettier",
  resetMocks: false,
  resetModules: false,
  resolver: undefined,
  restoreMocks: false,
  rootDir: "/Users/baidu/Documents/source/jestMaster/jest",
  roots: [
    "/Users/baidu/Documents/source/jestMaster/jest",
  ],
  runner: "/Users/baidu/Documents/source/jestMaster/jest/packages/jest-runner/build/index.js",
  setupFiles: [
  ],
  setupFilesAfterEnv: [
    "/Users/baidu/Documents/source/jestMaster/jest/testSetupFile.js",
  ],
  skipFilter: false,
  skipNodeResolution: undefined,
  slowTestThreshold: 5,
  snapshotFormat: undefined,
  snapshotResolver: undefined,
  snapshotSerializers: [
    "/Users/baidu/Documents/source/jestMaster/jest/packages/pretty-format/build/plugins/ConvertAnsi.js",
    "/Users/baidu/Documents/source/jestMaster/jest/node_modules/jest-snapshot-serializer-raw/lib/index.js",
  ],
  testEnvironment: "/Users/baidu/Documents/source/jestMaster/jest/packages/jest-environment-node/build/index.js",
  testEnvironmentOptions: {
  },
  testLocationInResults: false,
  testMatch: [
    "**/__tests__/**/*.[jt]s?(x)",
    "**/?(*.)+(spec|test).[tj]s?(x)",
  ],
  testPathIgnorePatterns: [
    "/test-types/",
    "/__arbitraries__/",
    "/node_modules/",
    "/examples/",
    "/e2e/.*/__tests__",
    "/e2e/global-setup",
    "/e2e/global-teardown",
    "\\.snap$",
    "/packages/.*/build",
    "/packages/.*/src/__tests__/setPrettyPrint.ts",
    "/packages/jest-core/src/__tests__/test_root",
    "/packages/jest-core/src/__tests__/__fixtures__/",
    "/packages/jest-cli/src/init/__tests__/fixtures/",
    "/packages/jest-haste-map/src/__tests__/haste_impl.js",
    "/packages/jest-haste-map/src/__tests__/dependencyExtractor.js",
    "/packages/jest-haste-map/src/__tests__/test_dotfiles_root/",
    "/packages/jest-repl/src/__tests__/test_root",
    "/packages/jest-resolve-dependencies/src/__tests__/__fixtures__/",
    "/packages/jest-runtime/src/__tests__/defaultResolver.js",
    "/packages/jest-runtime/src/__tests__/module_dir/",
    "/packages/jest-runtime/src/__tests__/NODE_PATH_dir",
    "/packages/jest-snapshot/src/__tests__/plugins",
    "/packages/jest-snapshot/src/__tests__/fixtures/",
    "/packages/jest-validate/src/__tests__/fixtures/",
    "/packages/jest-worker/src/__performance_tests__",
    "/packages/pretty-format/perf/test.js",
    "/e2e/__tests__/iterator-to-null-test.ts",
  ],
  testRegex: [
  ],
  testRunner: "/Users/baidu/Documents/source/jestMaster/jest/packages/jest-circus/runner.js",
  testURL: "http://localhost",
  timers: "real",
  transform: [
    [
      "\\.[jt]sx?$",
      "/Users/baidu/Documents/source/jestMaster/jest/packages/babel-jest/build/index.js",
      {
      },
    ],
  ],
  transformIgnorePatterns: [
    "/node_modules/",
    "\\.pnp\\.[^\\/]+$",
  ],
  unmockedModulePathPatterns: undefined,
  watchPathIgnorePatterns: [
    "coverage",
  ],
    }

**重点如下**

image.png