Jest常见问题及解决方案 | Jest 避坑经验

文摘   2025-01-10 11:05   美国  

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 functionconsole.log(myFunction.mock.calls); // access mock call historyconsole.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 methodexpect(spy).toHaveBeenCalled(); // check if the method has been calledconsole.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 testedexport 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 testedexport function A() {  return "A"}export function B() {  return A()}// Test codeimport * 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()}// ORfunction 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 方法)

Happy UT!


微策略 商业智能
微策略 MicroStrategy (Nasdaq: MSTR) 是企业级分析和移动应用软件行业的佼佼者。关注我们了解行业资讯、技术干货和程序员日常。
 最新文章