Jest 是 Facebook 开发的JavaScript 单元测试框架。它被广泛用于测试 JavaScript代码,包括 React 应用、Node.js 应用以及其他 JavaScript 库和框架。我们公司目前绝大部分JavaScript 源码都采用它进行单元测试,相信对于大家来说并不陌生。在这里我想和大家分享一下我们在使用Jest做单元测试的过程中所踩过的有意思的坑以及相应的解决方案,抛砖引玉,一起探讨。
Jest Mock 和 Spy
在这之前先简单介绍下在单元测试中常用到的Mock 和 Spy 技术,这两者主要用于创建测试mock 对象以替换代码中的真实依赖。
Mock 是指在测试过程中创建函数或模块的伪实现,用于控制其行为。可以使用 Mock 来模拟特定场景或响应,以确保代码按预期运行。Jest 提供了内置的 Mock 功能,可以使用 jest.fn() 或手动创建 Mock 对象。
const myFunction = jest.fn();
myFunction.mockReturnValue('mocked value');
myFunction(); // invoke the mock function
console.log(myFunction.mock.calls); // access mock call history
console.log(myFunction.mock.results); // access mock results
Spy 是指在不修改函数或模块实现的情况下,观察其行为。Spy 允许跟踪函数调用、参数和返回值,以确保代码与其依赖正确交互。Jest 提供了 jest.spyOn() 来创建对象或函数的 Spy。
const myObject = {
myMethod: () => {
// orginal implementation
},
};
const spy = jest.spyOn(myObject, 'myMethod');
myObject.myMethod(); // invoke spy method
expect(spy).toHaveBeenCalled(); // check if the method has been called
console.log(spy.mock.calls); // access mock call history
Mock 用于替换函数或模块的实现,而 Spy 则用于在不修改实现的情况下观察其行为。这两种技术对于测试和隔离代码依赖非常有价值。
问题及解决方案
问题1
jest.spyOn cannot redefine property
有的时候我们想要spy某个module的方法,却遇到这个错误”cannot redefine property”.
原因
jest.spyOn 用于创建 Spy时可以替换现有函数或对象属性, 在其内部实现中是通过调用Object.defineProperty来做替换/inject,然而因为某些原因(当 JavaScript 被转换时,例如使用 Babel 或 TypeScript时,属性的 configurable 特性可能会被意外设置为 false。这种情况通常发生在代码被预处理的测试环境中),这个属性变成了非可配置属性, 从而导致了这个错误。
解决方案
把需要spy的module mock一下,这样Jest 会创建一个可控的 Mock 模块实例。该实例的所有属性默认是 configurable: true,而不是直接使用原始模块加载的内容。
jest.mock('path/to/module', () => {
return {
...jest.requireActual('path/to/module'), // still to use the original implementation
// If you want to mock some of the method implementation, you can add it subsequently
};
});
问题2
测试过程Test Case 之间相互干扰
原因
当测试用例之间共享同一个 Mock 对象或模块时,可能会引发状态污染,导致用例间相互干扰。
解决方案
为避免 Mock 数据被污染,有以下的一些建议:
1. 清理Mock状态
推荐在每个测试用例开始或结束时清理 Mock 状态, 对此,Jest提供了如下的方法:
jest.clearAllMocks():清除所有 Mock 函数的调用记录,但依然保留了Mock行为(如mockReturnValue)。
jest.resetAllMocks():清除所有 Mock 函数的调用记录,同时重置所有 Mock 实现为初始状态。
jest.restoreAllMocks():恢复所有被 spyOn 的原始实现。
示例代码如下:
beforeEach(() => {
jest.clearAllMocks(); // clear mock call records
});
afterEach(() => {
jest.restoreAllMocks(); // restore to original implementation
});
另外,jest 也提供了jest.resetModules() 来强制清除模块缓存。这个仅适用于我们是在每个Test Case里面动态加载这个module时使用,是一个比较重量级的解决方案。如果模块无需动态状态隔离,采用前面提供的轻量级API即可。
2. 尽量使用一次性Mock
在Mock返回值时,我们可以使用mockReturnValue 或者mockReturnValueOnce。mockReturnValue 设置的返回值是全局共享的,后续测试如果未重新设置,可能使用了前一个测试的返回值。而mockReturnValueOnce 的返回值只在当前调用中有效,用完后会回退到默认行为。所以我们推荐尽量使用mockReturnValueOnce。
🔽 但是在使用mockReturnValueOnce的过程中,请确保在你的测试用例流程中,这个returnValue有被消耗掉。如果你定义了,却没有被消耗,那么它还是会影响下一个测试用例。如果需要,可以在测试开始前,调用jest.resetAllMocks() 来重置所有的Mock实现。
此外,还有一些其他的方法也能避免测试用例之间互相影响,比如使用Mock工厂/独立的Mock文件等等,其核心都是隔离状态。我们可以从这个主旨出发,改造出适合自己的方式。
问题3
测试异步逻辑失败
Jest 支持测试异步代码,测试用例需要添加 async 关键字。如果测试的是一个单一的异步函数,只需将测试用例标记为 async 并在调用函数时使用 await即可。
it('Test async logic', async () => {
await callAsyncFunc();
// test validation
});
但这里要讨论的情况更复杂一些。被测试的函数本身并不是异步函数,但是它里面调用了异步逻辑。await并不适用于这个情况。我们来看个例子:
// Code to be tested
export function handleUserMessage(callback) {
// do something
callChatMessageAPI().then(res=>callback(res))
}
// Test code
it('handle response with no manipulation requests', async () => {
const callback = jest.fn()
handleUserMessage(callback) // call the method to be tested
expect(callback).toHaveBeenCalled()
})
在这个例子当中,做assertion时会失败。
原因
在上面的测试逻辑中,没有等到异步代码(callChatMessageAPI)执行结束,就做assertion,callback还没有被调用到。
解决方案
我们需要确保所有的异步代码都执行结束了,再去执行assertion。常规的解决方案比如我们可以在调用了被测试方法之后,通过一些wait/sleep的逻辑来确保执行结束,但这可能会导致无谓的等待时间,降低测试效率。那么我们有没有更精确的方法来确保异步逻辑已经执行完毕了呢?
Jest默认都是在Node.js环境中运行。Node.js 提供了process.nextTick()的API,用于将函数加入到当前事件循环的 "next tick 阶段",会在当前 JavaScript 执行栈清空后立即执行。在Jest测试中,我们可以使用 await new Promise(process.nextTick()), 可以确保所有在 nextTick 队列中的任务(如 Promise 回调或事件触发的逻辑)完成后再继续执行测试。但是如果异步逻辑测试中有多层promise嵌套回调,process.nextTick() 可能会失效,这个时候我们还可以尝试用另一个方案await new Promise(setImmediate)。简单而言,setImmediate会比nextTick trigger更晚,nextTick依然属于当前的事件循环,而nextTick属于下一事件循环,它会等所有 I/O 操作和其他微任务(如 Promise)执行完成后,才会执行。具体实践过程中,我们可以根据实际情况来选用。在使用setImmediate时,如果你的测试环境时‘js dom’,你可能会遇到setImmediate not defined 的错误,记得先正确import这个方法:
import {setImmediate} from 'timers'
// Test code
it('handle response with no manipulation requests', async () => {
const callback = jest.fn()
handleUserMessage(callback) // call the method to be tested
await new Promise(process.nextTick()) // or use await new Promise(setImmediate)
expect(callback).toHaveBeenCalled()
})
问题4
一个module的同一个方法有多个mock 实例,导致assertion失败
假设我想测试module C 中的一个方法,module C依赖了module A和B,而A/B/C 之间是俩俩互相依赖。在这个被测试的方法中,调用了module B的一个方法,于是我需要mock这个方法,并在测试结束后对它的状态进行assertion
jest.mock('path/to/fileB', () => {
return {
...jest.requireActual('path/to/fileB')
methodToBeMocked: jest.fn()
};
});
it('test methodInFileC', () => {
const mockMethod = jest.spyOn(fileB, 'methodToBeMocked')
//invoke the logic which would invoke the call the 'methodToBeMocked'
methodInFileC()
expect(mockMethod).toHaveBeenCalled()
})
但是,assertion失败了!
原因
通过debug,我们发现 methodToBeMocked 确实被调用了。然后再调试 Jest 源代码后,我们发现jest为 methodToBeMocked 创建了两个 mock 实例,一个是 module C 中import用到的,另一个是 module A 中import用到的。在这个过程中,module C中的mock 实例先被创建, module A 中的 mock 实例是在稍后创建的,在调用module c方法过程中,它会使用 module C 中的 methodToBeMocked 实例,并且状态信息被记录在这个实例中; 而当我们进行assertion 时,Jest 会使用 后面File A 中的 mock 实例,上面没有被调用的状态信息,assertion当然会失败了。
解决方案
通过分析,推测根本原因是因为A/B/C之间的循环依赖。去掉循环依赖(例如把methodToBeMocked挪到一个单独的文件),这个问题就没有了。这也提醒我们:
在写代码的过程中,应该避免引入循环依赖。
如果被测试的代码存在循环依赖,mock相关方法时一定要特别小心,最好将将待 Mock 的方法移至没有循环依赖的文件中再进行测试。
问题5
测试和要被mock的方法在同一个模块,mock不生效
我们在测试一个函数的功能的过程中,常常需要mock 其他被依赖方法的输出。如果被mock方法和被测试的方法在同一个模块中的时候,我们会发现mock并不生效。我们先看看下面简化过的例子
// Code of utils to be tested
export function A() {
return "A"
}
export function B() {
return A()
}
// Test code
import * as utils from "utils"
it('test method B', () => {
jest.spyOn(utils, 'A').mockReturnValue('mockA')
expect(utils.B()).toBe('mockA')
})
第11行的断言会失败,调用会返回“A”而不是“mockA”。
原因
这是jest框架的一个限制。因为需要测试B,就需要先加载 B所在的模块实例,B和A都被加载,此时B里面所调用的A也是来自被加载的实例。这个时候我们再去spy B,B会有一个mock的实现,但是,它已经替代不了A里面的那个实现了。更多详细的讨论可以参考这里https://github.com/jestjs/jest/issues/936.
解决方案
一般我们建议:
把要mock的方法和要测试的方法隔离在不同的模块中
或者,不要去mock A,而是mock 被A依赖的但不在同一个模块中的方法。例如你可能在A里调用了fetch,那么你可以mock fetch。
如果实在有需要,那可以试试下面取巧的方法。在方法B的实现中,不要去直接调用A,而是类似于先去动态加载A,再去调用A,因为动态加载发生在spy之后,这样调用到的A就是mock的实例了。
export function B() {
return exports.A()
}
// OR
function B() {
const utils = require("./utils");
return utils.A()
}
结束语
在用jest的过程中我们可能遇到各种各样的问题,以上所提到的只是其中的一小部分,这里的解决方案可能并不是唯一或最优,描述过程中可能也不尽精确,欢迎大家批评指正,多多交流。最后再和大家分享几个在调试Jest过程中有用的源码文件:
node_modules/jest-environment-jsdom/node_modules/jest-mock/build/index.js
包含 Mock 创建及其状态维护逻辑node_modules/@jest/expect/node_modules/expect/build/spyMatchers.js
包含 Matchers 的具体检查逻辑(如 toHaveBeenCalled)node_modules/@jest/expect/node_modules/expect/build/index.js
Matchers 检查的入口点(如 makeThrowingMatcher 方法)