单元测试离不开Jest,但是Jest的实现方式你知道吗?尤其jest.fn(), jest.mock(), jest.spyOn()到底都是什么鬼?
一: 首先看看jest.fn()是什么
图片展示得出几个结论
- jest是个Object或者Class Instance,他有一个方法/函数fn
- 这个fn函数接受一个函数
- 经过fn处理后,返回的是一个新的函数,即 [Function: mockConstructor]
- 返回的新函数,莫名其妙的增加了新的方法,比如mock这个getter,还有mockImplementation
- 因此这个fn是一个工厂函数,接受的参数是个原始函数,返回一个模拟函数
因此有三个参与者
- 工厂函数 Factory Func
- 原始函数 Original Func
- 模拟函数 Mocked Func
二: 继续看看返回的mock的getter是什么
- 可见这个mock是一个存储instances【this实例】和results【运行结果】的东西,暂且不表。
三: Jest的文档展示mock这个plain object的三个key分别是
- Calls: 存储每次调用被动函数的参数
- Instances:存储每次调用原始函数(副本)的this对象
- Results:存储每次调用被动函数的结果
调用被动函数产生的结果只有三种
- 返回结果value
- 没有返回任何value,而是undefined
- 抛出错误Error
四: Expect是什么鬼?其实就是展示mock这个对象的语法糖
expect(fn).toBeCalled()
expect(fn).toBeCalledTimes(n)
expect(fn).toBeCalledWith(arg1, arg2, ...)
expect(fn).lastCalledWith(arg1, arg2, ...)
为了方便查看mock对象的三个key的结果,Jest提供了上面的几个语法糖。比如.toBeCalledTime()就是Results.length
5: 明白了jest原理,我们来手写一个工厂Jest函数吧!
先简单做个工厂函数,即,套子。注意是:复制一个原始函数。
// object literal
const jest={ }
function fn(impl= ()=>{} ) {
const mockFn = function(...args) {
return impl(...args);
};
return mockFn;
}
jest.fn=fn;
复制代码
解读:这个套子等于什么都没有做,只是原封不动复制了原始函数。
测试:
然后进一步,利用闭包closure记录一下mock的calls数组
function fn(impl= ()=>{} ) {
const mockFn = function(...args) {
mockFn.mock.calls.push(args);
return impl(...args);
};
// 这里是mock对象对应的calls
mockFn.mock = {
calls: []
};
return mockFn;
}
复制代码
测试:
解读:
- 工厂函数利用闭包原理,包原始函数包裹,并且隔离起来,生成一个新的函数,同时附加了监控数组。
- 注意⚠️:模拟函数跟原始函数不是一个函数。证明如下图。
- 且,只有在调用模拟函数的时候才能被监控,调用原始函数没有意义,因为二者指向不同的内存地址,之所以调用模拟函数,是因为mock的意义就是这样。
最后完整实现
function fn(impl = () => {}) {
const mockFn = function(...args) {
mockFn.mock.calls.push(args);
mockFn.mock.instances.push(this);
try {
const value = impl.apply(this, args);
mockFn.mock.results.push({ type: 'return', value });
return value;
} catch (value) {
mockFn.mock.results.push({ type: 'throw', value });
throw value;
}
}
mockFn.mock = { calls: [], instances: [], results: [] };
return mockFn;
}
复制代码
当然,真实的jest的mock函数比这个复杂,见 jestjs.io/docs/mock-f… 但是区别也仅是增加了更多的方法,如 mockImplementationOnce,等(见上图)
延伸总结
const mockedFunc= jest.fn( originalFunc );
mockedFunc可以看成是原始函数的加强版,注入了监控方法而已。其他核心没有变化。
即,此时如果调用mockedFunc(), 则相当于 originalFunc().
复杂的例子
const myMockFn = jest.fn(cb => cb(null, true));
myMockFn((err, val) => console.log(val));
// > true
step1: 简化 myMockFn ~= (cb)=> {
return cb(null, true);
}
step2: myMockFn((err, val) => console.log(val)) 相当于curry方法
return console.log(true)
复制代码
继续理解除了监控方法外的其他方法
- 除了监控数组mock之外(getter)
- 还有其他方法被附加到了mockedFunc上面,比如mockImplementation,mockImplementationOnce,mockReturnValue等
- 怎么理解?
function fn(impl = ()=>{} ) {
const mockFn = function(...args) {
mockFn.mock.calls.push(args);
// 如果提供了value则直接短路,不执行原始函数
if(mockReturnValue.value){
return mockReturnValue.value;
}
// 如果提供替代的函数则跳过原始函数
if(this.mockImplementation){
return this.mockImplementation(...args);
}
return impl(...args);
};
...
mockFn.mockReturnValue = (value)=>{
this.mockReturnValue=value;
};
mockFn.mockImplementation = (func)=>{
this.mockImplementation=func;
};
return mockFn;
}
复制代码
明白了上面的psuedo代码,看jest官方例子就简单了,比如
过程:
- 首先需要jest.mock('../foo') 相当于把jest.fn(foo)一下,即,套住foo;
- 如果没有1,则下面foo.mockImplementation...会报错,因为foo没有这个方法;
- 一旦使用了1,则原来的foo已经不再是原来的foo了; 而是一个副本,一个增加了方法的副本;
- foo.mockImplementation(() => 42); 其实就是
const newFoo= jest.fn(foo).mockImplementation(()=>42); 此时 newFoo()调用则绕过了原始的foo;当然由于3的存在,foo的名字没有变化,还是foo。
官方的这个例子是怎么回事?new哪里来的?
看了上面我们的手写,应该明白此时的fn工厂函数其实是个 function constructor,即,在远古时代,程序员靠function constructor来代替class,生成多个instance,即 instance1= new myMock()... 同时来绑定this,验证:
更多的例子
const { checkIfExists } = require("../../functions/simple");
const fs = require("fs");
const { readFileSync } = require("fs");
jest.mock("fs");
jest.mock("../../functions/simple");
// 尤其全局的mock;每次mock都会记录在mock的数组中;所以需要清空。
beforeEach(function () {
jest.clearAllMocks();
});
describe("understand module mock w. fs", () => {
test("should return true if file exists", () => {
// 如果不mock;则mock module返回的是undefined;合理。需要你去mock implementation;
// 如果不做,则类似 jest.fn();
const fileName = "file.txt";
const reading= fs.readFileSync(fileName);
console.log(reading);
expect(fs.readFileSync.mock.calls[0][0]).toBe(fileName);
});
});
describe("understand jest mock fun", () => {
// 可以继续使用const不会跟mock的冲突
test("method 1 to mock", () => {
const checkIfExists1 = jest.fn(() => true);
const value = checkIfExists1();
expect(checkIfExists1).toBeCalledTimes(1);
expect(checkIfExists1()).toBeTruthy();
console.log(checkIfExists1.mock);
});
// toBeTruthy()必须调用();不然可以使用 mockednFunc.mock.results[0].value;
// expect(checkIfExists).toBeTruthy(); 因为函数本身不是null,所以一定truthy
test("method 2 to mock", () => {
checkIfExists.mockImplementation(() => {
console.log("mockImplementation");
});
checkIfExists();
expect(checkIfExists).toBeCalledTimes(1);
expect(checkIfExists).toBeTruthy();
});
test("method 3 to mock", () => {
checkIfExists.mockImplementation();
checkIfExists();
expect(checkIfExists).toBeCalledTimes(1);
expect(checkIfExists()).not.toBeTruthy();
});
// 如果mock的时候么有implementation等,则返回的是undefined; 合理,因为就是 jest.fn()
test("method 4 to mock original", () => {
checkIfExists();
expect(checkIfExists).toBeCalledTimes(1);
expect(checkIfExists()).toBeTruthy();
});
});
describe("understand async mock", () => {
// async 的mock和assert需要看官方文档
test("method async 1 to mock", async () => {
const mockedFetch = jest.fn(() => {
return Promise.resolve({
json: () =>
Promise.resolve({
data: 1,
}),
});
});
return expect(mockedFetch().then((res) => res.json())).resolves.toEqual({
data: 1,
});
});
})
复制代码