随着你对代码的要求越来越高, 单元测试显得越来越重要.
这篇文章讲讲如何用 Jest 进行单元测试.
初安装
初始安装非常简单, 按照官方文档装好依赖就可以.
npminstall --save-dev jest
然后写一个方法, 注意用 CommonJs 导出
functionsum(a, b){return a + b;}
module.exports = sum;
在测试文件里用 require 引入方法
const sum =require('./sum');test('adds 1 + 2 to equal 3',()=>{expect(sum(1,2)).toBe(3);});
将测试命令添加进
package.json
{"scripts":{"test":"jest"}}
运行 npm run test 命令, 测试成功了.
PASS ./sum.test.js
✓ adds 1 + 2 to equal 3(5ms)
支持 ES6 模块导入
但是正常我们写方法都是通过 ES6 模块的方式导入导出的.
我们把方法改成 export 导出:
exportfunctionsum(a, b){return a + b;}
然后在测试文件中用 import 引入:
import{ sum }from'./sum.js';test('adds 1 + 2 to equal 3',()=>{expect(sum(1,2)).toBe(3);});
再次运行 npm run test, 发现报错了.
FAIL ./index.test.js
● Test suite failed to run
You appear to be using a native ECMAScript module configuration file, which is only supported when running Babel asynchronously.
意思是不支持 ES6 的模块化导入. 想想也正常, 因为 node 环境确实不支持 ES 模块化.
好在 Jest 能够支持模块化, 只需要安装 Babel. 按照文档我们来安装 Babel
npminstall --save-dev babel-jest @babel/core @babel/preset-env
然后在根目录新建一个
babel.config.js
文件:
module.exports ={presets:[['@babel/preset-env',{targets:{node:'current'}}]],};
Jest 会识别到 Babel 的配置文件来自动使用 Babel 转译模块化代码.
运行 npm run test, 成功了.
PASS ./index.test.js
✓ adds 1 + 2 to equal 3(2 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.743 s, estimated 1 s
第三方库 CommonJs 的支持情况
随着我们的业务变得越来越复杂, 我们开始使用第三方库了.
假设我们安装了 lodash, 并且用到了 sum 方法.
npminstall lodash --save
import{ sum }from'lodash'exportfunctionmysum(a, b){returnsum([a, b]);}
运行 npm run test, 还是成功了.
因为 lodash 是基于 CommonJs 导出的, 所以运行没有问题.
但是 lodash 库的引入方式有个问题, 虽然我们只用到了一个 sum 函数, 但是会把整个 lodash 库都打包进去, 无形中增加了项目的大小.
支持第三方库的 ES6 模块导入
我们把 lodash 库改成 lodash-es;
lodash-es 是 lodash 的 ES6 版本. 可以基于打包工具做 tree shaking.
import{ sum }from'lodash-es'exportfunctionmysum(a, b){returnsum([a, b]);}
运行 npm run test, 报错了!
FAIL ./index.test.js
● Test suite failed to run
Jest encountered an unexpected token
Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.
Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.
By default "node_modules" folder is ignored by transformers.
它的意思是说 Jest 默认是不会转换 “node_modules” 文件夹的, 由于 lodash-es 库中都是 ES6 模块文件, 导致 Jest 运行失败.
通过 Jest 的配置文件我们可以更改这一规则.
在根目录新建一个
jest.config.js
文件
参考配置文档我们来配置这个转换规则:
/** @type {import('jest').Config} */const config ={transformIgnorePatterns:['node_modules\/(?!(lodash-es|bar))'],};
module.exports = config;
注意这个规则是一个反向的规则, 所以理解起来会有点绕. 它表示匹配到的路径会被忽略转化, 所以我们希望 lodash-es 不被匹配到, 而其它的 node_modules 能够被匹配到.
运行 npm run test, 成功了!
第三方 ES6 库又依赖其它 ES6 库
随着我们项目的深入, 我们又使用了更复杂的 ES6 库, 库本身会依赖许多其它的 ES6 库. 因为现在前端项目都会基于构建工具来开发, 所以一些比较新的库会用纯 ES6 模块来输出.
比如这个 mdast-util-from-markdown 库, 它能把 markdown 文字转化成语法结构树. 它就是一个纯 ESM 库.
我用 vite + vue 开发了一个例子:
这个库会导出一个方法
fromMarkdown
, 假设我对这个方法进行了封装, 加了自已的业务逻辑, 然后我要来测试这个方法.
// utils.jsimport{ fromMarkdown }from'mdast-util-from-markdown'exportfunctionmyParseMarkdown(text){returnfromMarkdown(text)}
// utils.test.jsimport{ myParseMarkdown }from'./utils.js'test('测试一级标题',()=>{const tree =myParseMarkdown('# header1')const obj = tree.children[0]expect(obj.type).toBe('heading')expect(obj.depth).toBe(1)});
当我们写好了测试用例, 然后运行测试, 很显然测试肯定会失败. 因为这个库依赖了很多其它的库, 而它们都没有被 babel 转译. 我们也不可能在
jest.config.js
中把所有依赖的库名都添加进去, 那样也太麻烦了.
// jest.config.js/** @type {import('jest').Config} */const config ={transformIgnorePatterns:['node_modules\/(?!(mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities))'],};
module.exports = config;
当然我们也可以把 transformIgnorePatterns 改成一个空数组 [], 代表任何文件都进行转换. 这样也能运行成功. 只不过效率会低一点.
PASS src/utils.test.js
✓ 测试一级标题 (6 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 3.012 s
Ran all test suites.
使用 Esbuild 来打包模块
既然 Jest 会默认忽略转化 node_modules 里的模块, 那么我们自已给它打包一下不就好了. 用 Esbuild 可以把多个 js 模块打包成一个文件, 我们就用它把 utils.js 文件打包一下.
安装 esbuild
npm install esbuild --save-dev
写一个脚本命令
// package.json{"scripts":{"test-bundle":"esbuild ./src/utils.js --bundle --format=esm --outdir=./testBundle"}}
简单解释一下esbuild 命令的参数:
- –bundle: 代表把任何依赖项打包成一个文件.
- –format: 代表为生成的 js 文件设置输出格式。有三个可能的值:iife、cjs 与 esm. 在这里我们是希望生成的 js 文件能导出一个方法, 所以选择 ejs 或 esm 都可以.
- –outdir: 代表输出的文件夹位置.
然后更改测试用例中引用方法的位置.
// utils.test.jsimport{ myParseMarkdown }from'../testBundle/utils.js'
更改测试命令脚本:
// package.json{"scripts":{"test-bundle":"esbuild ./src/utils.js --bundle --format=esm --outdir=./testBundle","test":"npm run test-bundle && jest"}}
运行脚本
npm run test
, 成功了!
esbuild ./src/utils.js --bundle--format=esm --outdir=./testBundle
testBundle/utils.js 134.5kb
⚡ Done in 16ms
PASS src/utils.test.js
✓ 测试一级标题 (6 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.207 s
Ran all test suites.
Babel 能转译 ES6 模块导入吗
我们很好奇, babel 只是做了语法层面的转化, 可以将 Es6 语法转化成 commonjs 语法, 但是它并不能把各个 js 文件打包在一起.
我们来看下这个例子, 我们使用相同的 babel 配置来转译一个 index.js 文件.
// index.jsimport{ sum }from'lodash-es'import{ mySum }from'./sum'mySum(1,2)sum(1,2)
转译后的文件是这样的
"use strict";var _lodashEs =require("lodash-es");var _sum =require("./sum");(0, _sum.mySum)(1,2);(0, _lodashEs.sum)(1,2);
可以看到打包后的文件并没有把 lodash-es 和 sum 文件中的方法导入进来.
那么 Jest 是如何转译这些依赖的呢?
Jest 是如何转译 ES6 模块的
我们通过一条简单的测试用例来看下 Jest 是如何运作的.
源码解读基于 v30.0.0-alpha.6
- 收集配置 Jest 会根据命令行参数得到两个配置变量,
globalConfig
和configs
. 对于我们这个例子来说, 我们没有使用任何命令参数, 所以都是取的默认值. globalConfig 对应的是全局的配置. configs 对应的是项目的配置. - 获取文件依赖关系 利用
configs
和globalConfig
得到contexts
和hasteMapInstances
. 他们包含了项目中文件之间的依赖关系.// packages\jest-core\src\cli\index.tsconst{contexts, hasteMapInstances}=awaitbuildContextsAndHasteMaps( configs, globalConfig, outputStream,);
- 收集所有的测试路径 根据上面的 context 收集到所有的测试文件路径, 形成一个对象数组. 比如本例测试路径是 xx/xx/index.test.js
- 用 runner 开始跑测试 runner 用的是 jest-runner 库
await testRunner.runTests( tests, watcher, onTestFileStart, onResult, onFailure, testRunnerOptions );
- 开始对单个文件跑测试
// packages\jest-runner\src\runTest.tsasyncfunctionrunTestInternal(){// testFramework 是 packages\\jest-circus\\build\\runner.js result =awaittestFramework( globalConfig, projectConfig, environment, runtime, path, sendMessageToJest,);}
可以看到 testFramework 对应的是 packages/jest-curcus/src/legacy-code-todo-rewrite/jestAdapter.ts 中的jestAdapter
方法.constjestAdapter=()=>{ runtime.requireModule(testPath);}
这里 runtime.requireModule(testPath) 就是核心, 它执行了我们的测试文件, 也就是执行了 index.test.js. 要理解它是如何执行的, 我们来看一下 runtime 类中的这个方法. - runtime.requireModule requireModule 方法的解读仅为示意, 因此忽略了一些其它代码. a. requireModule
requireModule(from){const localModule ={children:[],exports:{},filename: modulePath,id: modulePath,isPreloading:false,loaded:false,path: path.dirname(modulePath),};this._loadModule(localModule, from)}_loadModule(localModule){this._execModule(localModule)}
b. _execModule_execModule(){const module = localModule // 定义一个 require 函数, 这个非常重要, 这个 require 函数就是上面的 requireModuel 函数, 循环调用. Object.defineProperty(module,'require',{value:this._createRequireImplementation(module, options),});// 1const transformedCode =this.transformFile(filename, options)// 2const script =this.createScriptFromCode(transformedCode, filename)// 3const vmContext =this._environment.getVmContext();// 4 runScript = script.runInContext(vmContext,{filename})// 5 compiledFunction = runScript[EVAL_RESULT_VARIABLE]// 6compiledFunction.call( module.exports, module,// module object module.exports,// module exports module.require,// require implementation module.path,// __dirname module.filename,// __filename);}
> _execModule 是真正执行代码的地方, 下面的数字对应上面的注释数字> > > 1. 先对源文件用 babel 进行转译, 以我们的 index.test.js 文件来说, 转译后的源文件变成:use scriptvar _index =require("./index")test('adds 1 + 2 to equal 3',function(){expect((0, _index.add)(1,2)).toBe(3);});
> 2. script 是一个脚本, 执行这个脚本后会返回一个对象, 对象的值是一个函数, 这个函数包裹了转译后的源文件.({"${EVAL_RESULT_VARIABLE}":function(module,exports,require){${transformedCode}\n}}
> 3. 获取全局作用域. 这个作用域里包含了像 test, expect 等函数.> 4. 运行脚本, runScript 就是上面返回的一个对象.> 5. compiledFunction 就是包裹测试源代码的函数.> 6. 执行测试源代码. 看到提供给这个函数的参数了吗? 比如 module.require 函数, 当执行到测试源代码中的 require(./index.js) 时, 它就会调用 requireModule 函数循环执行下去. Jest 就是通过这个方法来调用模块的.
以上就是 Jest 的简单入门, 希望可以帮助大家尽快的进入测试的世界.
最后推荐一个我自已写的 react 组件库 react-admin-kit, 对于中后台系统中常见的表单和表格能够提升很大的开发效率. 如果对你的工作有所帮助, 请不要吝啬你的 Star.💖
参考文章
从零开始实现一个 Jest 单元测试框架
版权归原作者 聪明杰 所有, 如有侵权,请联系我们删除。