首页 > 其他分享 >手写一个简易版 Jest

手写一个简易版 Jest

时间:2024-01-08 10:38:39浏览次数:29  
标签:const jest require 简易版 mock Jest 手写 sum fn

Jest 是流行的前端单元测试框架,可以用它来写 Node 代码或者组件的单测。

Jest 用起来并不难,但很多人用了多年依然不知道它是怎么实现的。

今天我们就一起来写一个简易版 Jest,写完之后你就知道它的实现原理了。

当然,我们先用一下:

mkdir jest-test
cd jest-test
npm init -y

创建个项目。

手写一个简易版 Jest_javascript

安装 jest 和它的 ts 类型:

npm install --save-dev jest @types/jest

创建一个 sum.js

function sum(a, b) {
  return a + b;
}

module.exports = sum;

还有它的单测文件 sum.test.js

const sum = require('./sum');

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

用 jest 跑下单测:

npx jest

手写一个简易版 Jest_前端_02

改成 4 再跑下:

手写一个简易版 Jest_javascript_03

手写一个简易版 Jest_前端_04

单测通过时,会打印成功,没通过时会打印错误信息。

这个 expect 的 api 叫做 Matcher(匹配器)。

Matcher 有很多 api:

手写一个简易版 Jest_Node.js_05

比如大于、小于、是否是某个类的实例、是否包含等等,能满足你的各种断言需求。

那当你测试的代码里依赖外部环境的部分,比如要读一个文件、要发送一个请求,这时候怎么测呢?

这种就需要 Mock 了。

比如这样:

手写一个简易版 Jest_javascript_06

function read() {
    const pkg = JSON.parse(fs.readFileSync('./package.json'));

    if(pkg.version === '1.0.0') {
        return 111;
    } else {
        return 222;
    }
}

这里 read 函数依赖了 fs 模块的 api。

想测试这个函数的不同分支,就可以 mock 它依赖的 fs 模块。

手写一个简易版 Jest_前端_07

const fs = require('fs');
const { sum, read } = require('./sum');

jest.mock('fs');

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

test('read test', () => {
    fs.readFileSync.mockReturnValue('{"version":"1.0.0"}')
    expect(read()).toBe(111);

    fs.readFileSync.mockReturnValue('{"version":"2.0.0"}')
    expect(read()).toBe(222);
})

这里用 jest.mock 对模块做了 mock,然后就可以自由修改它的 readFileSync 函数的返回值了。

手写一个简易版 Jest_javascript_08

这种 mock 模块的功能非常常用,比如你用 axios 发的请求,会在它返回什么值的时候做什么处理,这时候就可以 mock axios 模块,自由决定返回值。

此外,也可以 mock 函数:

手写一个简易版 Jest_JavaScript_09

可以拿到 mock 的函数被调用了几次,第几次调用的参数是什么:

手写一个简易版 Jest_javascript_10

手写一个简易版 Jest_bash_11

此外,jest 还有 beforeAll、afterAll、beforeEach、afterEach 这些钩子函数,可以在全部单测、每个单测执行前后来执行一些逻辑:

手写一个简易版 Jest_前端_12

综上,Matcher、Mock、钩子函数,这些就是 Jest 常用的功能了。

此外,jest 支持覆盖率检测:

npx jest --coverage

手写一个简易版 Jest_JavaScript_13

现在是 100%,我们加一点代码:

手写一个简易版 Jest_Node.js_14

因为 minus 这个函数没有测试,所以函数覆盖率就降低了:

手写一个简易版 Jest_JavaScript_15

那问题来了,这些 Matcher、Mock、覆盖率检测等功能,是怎么实现的呢?

我们能不能自己写一个类似的呢?

这个还是需要一些前置知识的,我们一点点来看:

手写一个简易版 Jest_Node.js_16

首先, jest、beforeAll、test、expect 这些 api 我们都没有从 jest 包导入,为什么就是全局可用的呢?

这是因为 jest 使用 node 的 vm 来跑的代码:

const vm = require('vm');

const context = {
    console,
    guang: 111,
    dong: 222
}

vm.createContext(context);

vm.runInContext('console.log(guang + dong)', context);

手写一个简易版 Jest_前端_17

它可以自己指定一个全局上下文,通过 vm 的跑的代码只能全局访问这些 api。

jest 就是通过这种方式跑的代码,注入了 jest、test、expect 等全局 api。

还有,为什么可以 mock 测试的模块依赖的模块,可以任意修改它的内容呢?

手写一个简易版 Jest_bash_18

这是因为 node 会把引入的模块放在 require.cache 里缓存,key 为文件绝对路径。

所以只要把 require.cache 里这个模块的 exports 改了,那不就是改了模块内容了么?

手写一个简易版 Jest_前端_19

require.cache['fs'] = {
    id: 'fs',
    filename: 'fs',
    loaded: true,
    exports: {
        readFileSync(filename) {
            return 'xxx';
        }
    }
}
const fs = require('fs');

console.log(fs.readFileSync('./package.json'));

手写一个简易版 Jest_Node.js_20

当然,这个和 jest 的行为不完全一样,这里必须在修改 require.cache 之后再 require 一次才会生效。

而 jest 那个不是:

手写一个简易版 Jest_前端_21

这是怎么做到的呢?

因为 jest 注入 vm 的 require 是自己实现的:

手写一个简易版 Jest_javascript_22

手写一个简易版 Jest_Node.js_23

它实现 require.cache 的时候是用的 Proxy 动态代理了 get 方法,动态读取了注册的模块的值。

总之,jest 的 require 并不完全是 node 的 require,所以它能实现 mock 等功能也不奇怪。

理清了这些之后,我们就可以动手写了。

创建 my-jest.js

const jest = {
    fn(impl = () => {}) {
        const mockFn = (...args) => {
            mockFn.mock.calls.push(args);
            return impl(...args);
        };
        mockFn.originImpl = impl;
        mockFn.mock = { calls: [] };
        return mockFn;
    },
    mock(mockPath, mockExports = {}) {
        const path = require.resolve(mockPath);
        require.cache[path] = {
            id: path,
            filename: path,
            loaded: true,
            exports: mockExports,
        };
    }
};

jest.mock 是模块 mock,而 jest.fn 是函数 mock。

也就是这个:

手写一个简易版 Jest_前端_24

它的实现就是返回一个函数,记录每次函数调用的参数。

然后实现 test、beforeAll、beforeEach 等 api:

const createState = () => {
    global["STATE"] = {
        testBlock: [],
        beforeEachBlock: [],
        beforeAllBlock: [],
        afterEachBlock: [],
        afterAllBlock: [],
        reports: []
    };
};

createState();

在全局放一个 STATE 变量,保存 test、beforeAll、beforeEach 等块。

const dispatch = event => {
    const { fn, type, name, pass } = event;
    switch (type) {
        case "ADD_TEST":
            const { testBlock } = global["STATE"];
            testBlock.push({ fn, name });
            break;
        case "BEFORE_EACH":
            const { beforeEachBlock } = global["STATE"];
            beforeEachBlock.push(fn);
            break;
        case "BEFORE_ALL":
            const { beforeAllBlock } = global["STATE"];
            beforeAllBlock.push(fn);
            break;
        case "AFTER_EACH":
            const { afterEachBlock } = global["STATE"];
            afterEachBlock.push(fn);
            break;
        case "AFTER_ALL":
            const { afterAllBlock } = global["STATE"];
            afterAllBlock.push(fn);
            break;
        case "COLLECT_REPORT":
            const { reports } = global["STATE"];
            reports.push({ name, pass });
            break;
    }
};

const test = (name, fn) => dispatch({ type: "ADD_TEST", fn, name });
const afterAll = (fn) => dispatch({ type: "AFTER_ALL", fn });
const afterEach = (fn) => dispatch({ type: "AFTER_EACH", fn });
const beforeAll = (fn) => dispatch({ type: "BEFORE_ALL", fn });
const beforeEach = (fn) => dispatch({ type: "BEFORE_EACH", fn });

test、beforeAll、beforeEach 这些 api 就是往全局 STATE 的不同数组里 push 函数。

然后运行的时候就是从这些数组里把函数取出来跑:

const vm = require("vm");
const fs = require("fs");

const testPath = process.argv.slice(2)[0];
const code = fs.readFileSync(testPath, { encoding: 'utf8'});

const context = {
    console,
    jest,
    require,
    afterAll,
    afterEach,
    beforeAll,
    beforeEach,
    test,
};


(async () => {
    vm.createContext(context);
    vm.runInContext(code, context);

    const { testBlock, beforeEachBlock, beforeAllBlock, afterEachBlock, afterAllBlock } = global["STATE"];

    for(let i = 0; i< beforeAllBlock.length; i++) {
        await beforeAllBlock[i]();
    }

    for(let i = 0; i< testBlock.length; i++) {
        const item = testBlock[i];
        const { fn, name } = item;
        try {
            await beforeEachBlock.map(async (beforeEach) => await beforeEach());

            await fn.apply(this);

            dispatch({ type: "COLLECT_REPORT", name, pass: 1 });

            await afterEachBlock.map(async (afterEach) => await afterEach());
            console.log(`${name} passed`);

        } catch (error) {
            dispatch({ type: "COLLECT_REPORT", name, pass: 0 });

            console.error(error);
            console.log(`${name} error`);
        }
    }

    for(let i = 0; i< afterAllBlock.length; i++) {
        await afterAllBlock[i]();
    }

    const { reports } = global["STATE"];

    let passNum = 0;
    reports.forEach(item => {
        passNum += item.pass;
    })
    console.log(`All Tests: ${passNum}/${reports.length} passed`);
})();

从命令行传入的文件路径读取内容,然后用 vm.runInContext 执行它。

执行之后,test、beforeAll、beforeEach 等传入的函数就收集到了 STATE 里。

然后按照 beforeAll、beforeEach、fn、afterEach、afterAll 的顺序执行就好了。

记录每次是否通过,最后打印通过的单测数。

那 expect 呢?

expect 就是不同的 Matcher(匹配器),如果不匹配就抛异常:

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

我们先试试看:

const { sum } = require('./sum');

test('sum test1', () => {
    expect(sum(1, 2)).toBeGreaterThan(2);
});

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

创建个单测文件,然后用我们写的 jest 跑一下:

手写一个简易版 Jest_JavaScript_25

手写一个简易版 Jest_JavaScript_26

单测通过和不通过的情况都没问题。

我们再来试试 mock:

手写一个简易版 Jest_Node.js_27

mock 模块和函数都没问题。

然后是 beforeAll 和 beforeEach:

手写一个简易版 Jest_Node.js_28

也没啥问题。

现阶段全部代码如下:

const vm = require("vm");
const fs = require("fs");

const testPath = process.argv.slice(2)[0];
const code = fs.readFileSync(testPath, { encoding: 'utf8'});

const dispatch = event => {
    const { fn, type, name, pass } = event;
    switch (type) {
        case "ADD_TEST":
            const { testBlock } = global["STATE"];
            testBlock.push({ fn, name });
            break;
        case "BEFORE_EACH":
            const { beforeEachBlock } = global["STATE"];
            beforeEachBlock.push(fn);
            break;
        case "BEFORE_ALL":
            const { beforeAllBlock } = global["STATE"];
            beforeAllBlock.push(fn);
            break;
        case "AFTER_EACH":
            const { afterEachBlock } = global["STATE"];
            afterEachBlock.push(fn);
            break;
        case "AFTER_ALL":
            const { afterAllBlock } = global["STATE"];
            afterAllBlock.push(fn);
            break;
        case "COLLECT_REPORT":
            const { reports } = global["STATE"];
            reports.push({ name, pass });
            break;
    }
};

const createState = () => {
    global["STATE"] = {
        testBlock: [],
        beforeEachBlock: [],
        beforeAllBlock: [],
        afterEachBlock: [],
        afterAllBlock: [],
        reports: []
    };
};

createState();

const jest = {
    fn(impl = () => { }) {
        const mockFn = (...args) => {
            mockFn.mock.calls.push(args);
            return impl(...args);
        };
        mockFn.originImpl = impl;
        mockFn.mock = { calls: [] };
        return mockFn;
    },
    mock(mockPath, mockExports = {}) {
        const path = require.resolve(mockPath, { paths: ["."] });
        require.cache[path] = {
            id: path,
            filename: path,
            loaded: true,
            exports: mockExports,
        };
    }
};

const test = (name, fn) => dispatch({ type: "ADD_TEST", fn, name });
const afterAll = (fn) => dispatch({ type: "AFTER_ALL", fn });
const afterEach = (fn) => dispatch({ type: "AFTER_EACH", fn });
const beforeAll = (fn) => dispatch({ type: "BEFORE_ALL", fn });
const beforeEach = (fn) => dispatch({ type: "BEFORE_EACH", fn });

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

const context = {
    console,
    jest,
    expect,
    require,
    afterAll,
    afterEach,
    beforeAll,
    beforeEach,
    test,
};

(async () => {
    vm.createContext(context);
    vm.runInContext(code, context);

    const { testBlock, beforeEachBlock, beforeAllBlock, afterEachBlock, afterAllBlock } = global["STATE"];

    for(let i = 0; i< beforeAllBlock.length; i++) {
        await beforeAllBlock[i]();
    }

    for(let i = 0; i< testBlock.length; i++) {
        const item = testBlock[i];
        const { fn, name } = item;
        try {
            await beforeEachBlock.map(async (beforeEach) => await beforeEach());

            await fn.apply(this);

            dispatch({ type: "COLLECT_REPORT", name, pass: 1 });

            await afterEachBlock.map(async (afterEach) => await afterEach());
            console.log(`${name} passed`);

        } catch (error) {
            dispatch({ type: "COLLECT_REPORT", name, pass: 0 });

            console.error(error);
            console.log(`${name} error`);
        }
    }

    for(let i = 0; i< afterAllBlock.length; i++) {
        await afterAllBlock[i]();
    }

    const { reports } = global["STATE"];

    let passNum = 0;
    reports.forEach(item => {
        passNum += item.pass;
    })
    console.log(`All Tests: ${passNum}/${reports.length} passed`);
})();

有的同学可能会说,jest 的错误打印不是这样的呀:

手写一个简易版 Jest_javascript_29

它会标记出具体的代码位置。

这个也很容易实现,直接用 @babel/code-frame 包就行:

const { codeFrameColumns } = require('@babel/code-frame');

const rawLines = `class Foo {
  constructor() {
    console.log("hello");
  }
}`;

const location = {
  start: { line: 3, column: 8 },
  end: { line: 3, column: 9 },
};

const result = codeFrameColumns(rawLines, location, {
  highlightCode: true
});

console.log(result);

手写一个简易版 Jest_JavaScript_30

只要传入开始和结束的行列号,就会打印这样的格式,很方便。

那么问题来了,如何获得出错位置的行列号呢?

答案很巧妙,就是通过错误堆栈:

手写一个简易版 Jest_Node.js_31

用正则匹配出来就行。

jest 内部也是这么实现的:

手写一个简易版 Jest_JavaScript_32

拿到错误 stack 的顶层 frame,解析出文件名和行列号。

还有一个问题,覆盖率是怎么实现的呢?

其实这个不是 jest 自己实现的,它是用的 istanbul。

istanbul 实现覆盖率检测是通过 AST 给函数加入一些埋点代码,也叫函数插桩。

比如这样的代码:

手写一个简易版 Jest_Node.js_33

我们用 istanbul 的 babel 插件处理下:

const babel = require('@babel/core');
const babelPluginIstanbul = require('babel-plugin-istanbul');

const res = babel.transformFileSync('./sum.js', {
    plugins: [
      [babelPluginIstanbul, {
        inputSourceMap: true
      }]
    ]
});

console.log(res.code);

就变成了这样:

手写一个简易版 Jest_bash_34

这些 ++ 很容易看懂就是计数,每执行一次都会计数。

而上面还有个 map 记录着所有函数、语句的信息和执行次数:

比如 sum 这个函数的开始结束的行列号:

手写一个简易版 Jest_bash_35

手写一个简易版 Jest_bash_36

它的执行次数。

那这样当插桩后的代码执行之后,覆盖率的数据不就收集到了么?

手写一个简易版 Jest_前端_37

也就是这个全局变量 global['__coverage']。

接下来就把这个覆盖率数据打印出来就好了。

这里需要用到 istanbul-lib-report 和 istanbul-lib-coverage 这俩包:

代码直接用文档中的实例代码就行。

比较多,不用细看:

const babel = require('@babel/core');
const babelPluginIstanbul = require('babel-plugin-istanbul');

const res = babel.transformFileSync('./sum.js', {
    plugins: [
      [babelPluginIstanbul, {
        inputSourceMap: true
      }]
    ]
});

eval(res.code);

const libReport = require('istanbul-lib-report');
const reports = require('istanbul-reports');
var libCoverage = require('istanbul-lib-coverage');

var map = libCoverage.createCoverageMap();
var summary = libCoverage.createCoverageSummary();

map.merge(global['__coverage__']);

map.files().forEach(function(f) {
    var fc = map.fileCoverageFor(f),
        s = fc.toSummary();
    summary.merge(s);
});

const context = libReport.createContext({
  coverageMap: map,
})

const report = reports.create('text')

report.execute(context)

只看效果就行:

手写一个简易版 Jest_javascript_38

手写一个简易版 Jest_Node.js_39

手写一个简易版 Jest_bash_40

可以看到,测试覆盖率是准的。

jest 就是用的这个:

手写一个简易版 Jest_bash_41

至此,我们对 jest 的实现原理就有了一个相对全面的了解。

总结

我们先用了一下 Jest,然后探究了下它的实现原理。

Jest 的核心功能就是 Matcher(expect 函数),Mock(函数 mock 和模块 mock),再就是钩子函数。

能在测试文件里直接用 test、jest、beforeAll、expect 等 api 是因为 Jest 是用 vm.runInContext 来运行的代码,可以自己指定全局上下文。

包括 require 也是 Jest 自己实现的版本,所以可以实现 Mock 的功能,当然,我们直接修改 require.cache 也可以实现类似功能。

我们实现了支持单测运行、支持钩子函数、支持 Mock 的简易版 Jest。

还有一些功能没实现:

比如错误打印代码位置,这个用 @babel/code-frame + 解析错误堆栈的行列号来实现。

比如覆盖率检测,这个直接用 istanbul 就行,它是通过函数插桩拿到覆盖率数据,放在一个 __corverage__ 的全局变量上,然后用别的包把它打印出来就行。

相信写完这个简易版 Jest,你会对 Jest 有一个更全面和深入的理解。

标签:const,jest,require,简易版,mock,Jest,手写,sum,fn
From: https://blog.51cto.com/u_15506823/9139572

相关文章

  • 反射、注解和反射的关系以及手写自己的注解。看完保证你能懂!
    1.一般我们会用反射来创建对象举个例子:先创建两个实体类Dog,Cat,然后再创建一个properties配置文件如下:bean=com.ref.Dog在后再通过反射来动态的创建这个两个实体类的对象:publicclassMyTest{privatestaticPropertiesproperties;static{try{p......
  • Spring学习记录之手写Spring框架
    Spring学习记录之手写Spring框架前言这篇文章是我第二次学习b站老杜的spring相关课程所进行的学习记录,算是对课程内容及笔记的二次整理,以自己的理解方式进行二次记录,其中理解可能存在错误,欢迎且接受各位大佬们的批评指正;关于本笔记,只是我对于相关知识遗忘时快速查阅了解使用,至......
  • Jest之单元测试入门
    一,测试平台1,使用nodejs工程二,准备工作1,npminstalljestsave-dev2,package.json的配置三,开始编写测试代码1,sum.js:业务逻辑代码(被测试)module.exports.sum=function(a,b){returna+b;}2,sun.test.js:测试代码,用于测试sum.js2.1,注意:一定要使用test.jsconstsum......
  • 手写topN算法-c语言
    #include<stdio.h>#include<malloc.h>structTreeHeap{intv;};typedefstructTreeHeapTreeHeap;staticvoidprint_bp(intbp[],intlen);voidcreate_treeheap(TreeHeap*treeheap,intdata[10],intbp[11]){treeheap->v=1;......
  • 使用Numpy实现手写数字识别
    1概要  用Python语言在只使用Numpy库的前提下,完成一个全连接网络的搭建和训练。2实现代码参考:https://github.com/binisnull/ann2.1环境设置  创建Python3.8.16的虚拟环境,激活并执行python-mpipinstallnumpy==1.18.5tensorflow-gpu==2.3.0Pillowmatplot......
  • 移动端手写板 + 模态框 + 弹框,前端监听移动端返回按钮
    今天的需求是把全屏的手写板改为同一个页面只占半屏的手写板,本来用的iframe,后面发现笔触和屏幕按下的位置不一样,然后用了jQuery的$.load(),发现用$.load会导致文件中的js不执行,后面还是重新开始,在同文件重新写了一个canvas手写板,然后发现了,canvas在全屏的时候没问题,在容器......
  • 手写滑动同步滚动进度条jq插件
    因需要一种滑动显示内容,并且带可拖动的进度条,即下面这种效果 找了很多插件,总有地方不能满足需求。于是决定自己手写,下面为完整源码:swiper.js1$.swiperCalculator=function(wrap,drag){2this.wrap=wrap;3this.drag=drag;4this.dWidth=drag......
  • 机器学习笔记(三)简单手写识别
    目标实现一个简单的手写识别的脚本,同样的,流程分五步走:读入数据初始化模型训练模型训练样本集乱序校验数据有效性前期准备前期需要将库导入,还需要进行一些初始化操作数据处理部分之前的代码,加入部分数据处理的库点击查看代码#加载飞桨和相关类库importpaddlefrom......
  • 手写一个 Zustand,只要 60 行
    提到状态管理,大家可能首先想到的是redux。redux是老牌状态管理库,能完成各种基本功能,并且有着庞大的中间件生态来扩展额外功能。但redux经常被人诟病它的使用繁琐。近两年,React社区出现了很多新的状态管理库,比如zustand、jotai、recoil等,都完全能替代redux,而且更简单。zusta......
  • [WPF]动手写一个简单的消息对话框
    消息对话框是UI界面中不可或缺的组成部分,用于给用户一些提示,警告或者询问的窗口。在WPF中,消息对话框是系统原生(user32.dll)的MessageBox,无法通过Style或者Template来修改消息对话框的外观。因此,当需要一个与应用程序主题风格一致的消息对话框时,只能自己动手造轮子了。确定“轮子......