本文首次发布于掘金,转载请注明来源。
一、mock模块
在Jest中,对模块进行mock非常简单,只需要使用jest.mock
即可,对于模块的mock主要有两种情况:
-
只mock模块中的非
default
导出对于只有非
default
导出的情况(如export const
、export class
等),只需要使用jest.mock
,返回一个对象即可,对象中包含有你想要mock的函数或者变量:// mock 'moduleName' 中的 foo 函数 jest.mock('../moduleName', () => ({ foo: jest.fn().mockReturnValue('mockValue'), }));
-
mock模块中的
default
导出对于
default
导出的mock,则不能返回一个简单的对象,而是需要在对象中包含一个default
属性,同时添加__esModule: true
。When using the factory parameter for an ES6 module with a default export, the __esModule: true property needs to be specified. This property is normally generated by Babel / TypeScript, but here it needs to be set manually. When importing a default export, it's an instruction to import the property named default from the export object
import moduleName, { foo } from '../moduleName'; jest.mock('../moduleName', () => { return { __esModule: true, default: jest.fn(() => 42), foo: jest.fn(() => 43), }; }); moduleName(); // Will return 42 foo(); // Will return 43
二、mock模块部分内容
如果只想mock模块中的部分内容,对于其他部分保持原样,可以使用jest.requireActual
来引入真实的模块:
import { getRandom } from '../myModule';
jest.mock('../myModule', () => {
// Require the original module to not be mocked...
const originalModule = jest.requireActual('../myModule');
return {
__esModule: true, // Use it when dealing with esModules
...originalModule,
getRandom: jest.fn().mockReturnValue(10),
};
});
getRandom(); // Always returns 10
三、mock模块内部函数
设想一种情况,有一个utils.ts
文件,内部导出了两个函数funcA
和funcB
,然后在funcB
中引用了funcA
:
// utils.ts
export const funcA = () => {
// ...
}
export const funcB = () => {
funcA();
// ...
}
这个时候在对funcB
进行单元测试时,如果想要对funcA
进行mock,会发现mock失败:
import { funcA, funcB } from '../src/utils';
jest.mock('../src/utils', () => {
const originalModule = jest.requireActual('../src/utils');
return {
...originalModule,
funcA: jest.fn(),
};
});
describe('utils.ts 单元测试', () => {
test('测试 funcB', () => {
funcB();
expect(funcA).toBeCalled();
});
});
运行单测会得到一个报错
很明显,我们对funcA
的mock失败了,为什么会有这样的结果呢,因为我们从模块外部导入的funcA
引用和模块内部直接使用的funcA
引用并不是同一个,通过jest.mock
修改funcA
并不会影响内部的调用。对于这种情况,建议的解决方法有两种:
- 拆分文件,将
funcA
拆分到不同的文件。这种做法可能会造成文件过多且分散的问题。 - 将相互调用的函数,作为一个工具类的方法来实现。即将互相调用的函数,放到同一个工具类中。
四、mock类(class
)构造函数中对其他成员函数的调用
当我们在mock一个class
的方法的时候,很简单地将类对象的对应方法赋值为jest.fn()
即可,但是对于在构造函数中调用的成员方法,却不能这样做。因为类里面的方法只能在实例化完成之后再进行mock,不能阻止constructor
中执行原函数。
这时,我们可以考虑一下,class
的本质是什么,class
是ES6中的语法糖,本质上还是ES5中的原型prototype
,所以类的成员方法本质上也是挂载到类原型上的方法,所以我们只需要mock类构造函数的原型上的方法即可:
class Person {
constructor() {
this.init();
// ...
}
public init() {}
}
Person.prototype.init = jest.fn();
五、mock类中的私有函数(针对TypeScript而言)
对于ts中类的私有函数(private
),无法直接获取(虽然说可以ts-ignore
忽略ts报错,不过不建议这样做),这时只需使用同样的方法,在类的原型上直接mock即可:
class Person {
private funcA() {}
}
Person.prototype.funcA = jest.fn();
六、mock对象的只读属性(getter
)
在单测中,对于可读可写属性我们可以比较方便地进行mock,直接赋值为对应的mocK值即可,如Platform.OS
。但是对于只读属性(getter
)的mock却不能直接这样写。通常对于只读属性(此处以document.body.clientWidth
为例)有以下两种mock方式:
-
通过
Object.defineProperty
Object.defineProperty(document.body, 'clientWidth', { value: 10, set: jest.fn(), });
-
通过
jest.spyOn
const mockClientWidth = jest.spyOn(document.body, 'clientWidth', 'get'); mockClientWidth.mockReturnValue(10);
七、使用toBeCalledWith
对参数中的匿名函数进行断言
我们需要对于某个方法测试时,有时需要断言这个方法以具体参数被调用,toBeCalledWith
可以实现这个功能,但是设想下面一种情况
export const func = (): void => {
if (/* condition 1 */) {
moduleA.method1(1, () => {
// do something
});
} else {
moduleA.method1(2);
}
}
在某种情况下,moduleA.method1
将会被传入参数1
和一个匿名函数,要怎么用toBeCalledWith
断言moduleA.method1
被以这些参数调用了呢?因为第二个参数是一个匿名函数,外部没办法mock。这个时候,我们可以使用expect.any(Function)
来断言:
moduleA.method1 = jest.fn();
// 构造出 condition 1
func();
expect(moduleA.method1).toBeCalledWith(1, expect.any(Function));
因为这里其实只关心moduleA.method1
是否被传入第二个参数且参数是否为一个函数,而不关心函数的具体内容,所以可以用expect.any(Function)
来断言。
八、mock localStorage
localStorage
是浏览器环境下的一个全局变量,挂载在window
下,在单测运行时(Node环境)是获取不到的,对于localStorage
,我们可以实现一个简单的mock:
class LocalStorageMock {
private store: Record<string, string> = {};
public setItem(key: string, value: string) {
this.store[key] = String(value);
}
public getItem(key: string): string | null {
return this.store[key] || null;
}
public removeItem(key: string) {
delete this.store[key];
}
public clear() {
this.store = {};
}
public key(index: number): string | null {
return Object.keys(this.store)[index] || null;
}
public get length(): number {
return Object.keys(this.store).length;
}
}
global.localStorage = new LocalStorageMock();
建议把mock放到单独的mocks文件中,在需要测试的地方,单独引入即可:
import './__mocks__/localStorage';
九、mock indexedDB
对于indexedDB,情况和localStorage
类似,它是浏览器环境下的一个事务型数据库系统,同样在Node环境中无法获取,但是由于indexedDB接口、类型较多,实现起来较为复杂,不建议自己实现,比较常见的做法是使用fake-indexeddb这个库,这个库使用纯js在内存中实现了indexedDB的各种接口,主要用于在Node环境中对依赖indexedDB的代码进行测试。
对于需要测试的文件,只需要在文件开头引入fake-indexeddb/auto
即可:
import 'fake-indexeddb/auto';
如果需要对所有的文件都引入fake-indexeddb
,那么只需要在jest配置中添加如下配置:
// jest.config.js
module.exports = {
// ...
setupFiles: ['fake-indexeddb/auto'],
};
或在package.json
中
"jest": {
...
"setupFiles": ["fake-indexeddb/auto"]
}
十、测试异步函数
在单测中,如果需要对异步函数进行测试,针对不同情况有如下操作:
-
callback
回调函数异步对于回调函数异步(如
setTimeout
回调),如果像同步函数一样进行测试,是没办法获取正确的断言结果的:export const funcA = (callback: (data: number) => void): void => { setTimeout(() => { callback(1); }, 1000); };
test('funcA', () => { funcA((data) => expect(data).toEqual(2)); });
像上面那样,
funcA
会在回调里传入1,单测里就算是直接断言结果为2,也是可以直接通过单测的:这是因为jest在运行完
funcA
后就直接结束了,不会等待setTimeout
的回调,自然也就没有执行expect
断言。正确的做法是,传入一个done
参数:test('funcA', (done) => { funcA((data) => { expect(data).toEqual(2); done(); }); });
在回调执行完之后显式地告诉jest异步函数执行完毕,jest会等到执行了
done()
之后再结束,这样就能得到预期的结果了。 -
Promise
异步除了回调函数外,另外一种很常见的异步场景就是
Promise
了,对于Promise
异步,不用像上面那么复杂,只需要在test
用例结束时,把Promise
返回即可:export const funcB = (): Promise<number> => { return new Promise<number>((resolve) => { setTimeout(() => { resolve(1); }, 1000); }); };
test('funcB', () => { return funcB().then((data) => expect(data).toEqual(1)); });
如果使用了
async/await
语法,就更简洁了,Promise
都不需要返回,像测试同步代码一样直接书写即可:test('funcB', async () => { const data = await funcB(); expect(data).toEqual(1); });
对于
Promise
抛出的异常,测试方法也和上面类似:// 抛出异常的方法 export const funcC = (): Promise<number> => { return new Promise<number>((resolve, reject) => { setTimeout(() => { reject('something wrong'); }, 1000); }); };
test('funcC promise', () => { return funcC().catch((error) => expect(error).toEqual('something wrong')); }); // or test('funcC await', async () => { try { await funcC(); } catch (error) { expect(error).toEqual('something wrong'); } });
十一、不执行jest.spyOn
mock的函数
我们知道,jest.fn
和jest.spyOn
都可以用来mock一个函数,区别是jest.fn
mock的函数不会去执行,而jest.spyOn
mock的函数是会去正常执行的。那么有没有什么办法让jest.spyOn
mock的函数不执行呢?其实上面已经用到了,在“mock对象的只读属性(getter
)”中,通过jest.spyOn
mock了一个getter
,然后使用mockReturnValue
来mock一个返回值,这个时候原函数就不会执行。
除此之外,使用mockImplementation
也有同样的效果:
mockFn.mockImplementation(() => {});
总结下来就是可以使用mockReturnValue
和mockImplementation
不执行jest.spyOn
mock的函数。
另外多说一个建议就是,能使用jest.fn
就尽量不要用jest.spyOn
,因为jest.spyOn
会执行原始代码,在统计单测覆盖率时会被统计进去,导致单测覆盖率看起来很高实际上却又很多代码没有相应单测。
十二、使用test.each
有时我们会遇到这种情况,要写大量单测用例,但是每个用例结构一样或相似,只有细微不同,比如测试某个format
函数对于不同的字符串的返回结果,或者调用一个类不同的成员方法但返回的结果类似(如都抛出错误或return null
等),对于这些情况,有时我们可以在单测内写一个数组然后遍历执行一下,但其实jest已经提供了应对这种情况的方法,即test.each
,举几个例子:
// each.ts
export const checkString = (str: string): boolean => {
if (str.length <= 0) {
throw new Error('mockError 1');
} else if (str.length > 5) {
throw new Error('mockError 2');
} else {
return true;
}
};
// each.test.ts
describe('each.ts 单元测试', () => {
test.each<{ param: string; expectRes: boolean | string }>([
{
param: '',
expectRes: 'mockError 1',
},
{
param: '123456',
expectRes: 'mockError 2',
},
{
param: '1234',
expectRes: true,
},
])('checkString', (data) => {
const { param, expectRes } = data;
try {
const result = checkString(param);
expect(result).toEqual(expectRes);
} catch (error) {
expect(error.message).toEqual(expectRes);
}
});
});
又比如在某种情况下,某个对象store
的所有方法都会抛出异常:
test.each<{
func: 'get' | 'delete' | 'add' | 'update';
param?: any;
}>([
{ func: 'get', param: ['mockKey'] },
{ func: 'delete', param: ['mockKey'] },
{ func: 'add', param: ['mockKey', 'mockValue'] },
{ func: 'update', param: ['mockKey', 'mockValue'] },
])('调用 store 的方法抛出异常', (data) => {
return store[data.func](...data.param).catch((err) => {
expect(err).toEqual('mockError');
});
});
除了test.each
外,还有describe.each
,更多具体用法可以参考test.each和describe.each
十三、使用.test.js
、.test.ts
、.test.tsx
这点是一个建议,建议单测文件以.test.js
、.test.ts
、.test.tsx
命名,如对于utils.ts
,建议对应的单测以utils.test.ts
命名,这样每个单测文件单单从文件名来说就具有清晰的语义,即这是一个单测文件,而不是一个具有具体功能的源码文件。
同时,在搜索文件或者全局搜索字符串时,列表里的文件更清晰可见容易辨认。更进一步来说,现在很多IDE的文件图片icon插件,针对不同的文件名结尾,都有不同的渲染,更加方便辨认:
十四、配合使用Jest Runner
插件
另外推荐一个VSCode插件,Jest Runner
,这个插件会在.test.js
、.test.ts
、.test.tsx
中,渲染几个按钮选项:
点击Run
或Debug
,可以只运行或调试某一个test
或者describe
,不需要重新全局npm run test
也不用单独jest
执行这个文件,极大提高写单测的效率:
这个插件只针对.test.js
、.test.ts
、.test.tsx
这几个文件类型有效,所以这也是上面建议单测文件使用使用.test.js
、.test.ts
、.test.tsx
命名的原因之一。
同时,插件提供的Debug
,也省去了繁琐的launch.json
配置,可以方便地进行断点调试。
十五、其他技巧
1. jest配合enzyme对React.forwardRef组件进行测试
对于React.forwardRef组件,假设有如下用例
test('render', () => {
const wrapper = mount(<Wrapper {...props} />);
expect(wrapper.find(/** ComponentName */).exists()).toBeTruthy();
});
-
对于普通的组件,“ComponentName”只需要填入对应组件的名字即可,如"Text"
expect(wrapper.find('Text').exists()).toBeTruthy();
-
但是对于使用了React.forwardRef来进行ref转发的组件,“ComponentName”则需要加上“ForwardRef”,如“ForwardRef(MyComponent)”
expect(wrapper.find('ForwardRef(MyComponent)').exists()).toBeTruthy();
2. 收集单元测试覆盖率
有以下几种方式收集单元测试覆盖率:
-
命令行执行全部单测并收集覆盖率
npx jest --coverage
-
命令行执行单个单测文件并收集覆盖率
npx jest src/utils/__tests__/utils.test.ts --coverage
-
在
jest.config.js
中配置collectCoverage
,同时设置collectCoverageFrom
来收集指定文件的覆盖率module.exports = { // ... collectCoverage: true, collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', ], };
这样在命令行执行
npx jest
时,就会自动收集覆盖率了。