JavaScript / Mocha - 如何测试是否等待函数调用

JavaScript / Mocha - How to test if function call was awaited

我想编写一个测试来检查我的函数是否使用 await 关键字调用其他函数。

我希望我的测试失败:

async methodA() {
   this.methodB();
   return true; 
},

我希望我的测试成功:

async methodA() {
   await this.methodB();
   return true;
},

我希望我的测试也能成功:

methodA() {
   return this.methodB()
       .then(() => true);
},

我有一个解决方案,方法是使用 process.nextTick 对方法进行存根并强制它 return 在其中伪造承诺,但它看起来很难看,我不想使用 [=16] =] 我的测试中也没有 setTimeout 等。

丑陋的异步-test.js

const { stub } = require('sinon');
const { expect } = require('chai');

const testObject = {
    async methodA() {
        await this.methodB();
    },
    async methodB() {
        // some async code
    },
};

describe('methodA', () => {
    let asyncCheckMethodB;

    beforeEach(() => {
        asyncCheckMethodB = stub();
        stub(testObject, 'methodB').returns(new Promise(resolve => process.nextTick(resolve)).then(asyncCheckMethodB));
    });

    afterEach(() => {
        testObject.methodB.restore();
    });

    it('should await methodB', async () => {
        await testObject.methodA();
        expect(asyncCheckMethodB.callCount).to.be.equal(1);
    });
});

测试函数调用中是否使用了 await 的聪明方法是什么?

我刚才也有同样的想法:如果能够以编程方式检测异步函数不是很好吗?事实证明,你做不到。如果你想得到可靠的结果,至少你不能这样做。

原因很简单:asyncawait 基本上是语法糖,由编译器提供。让我们看看在这两个新关键字出现之前我们是如何用 promises 编写函数的:

function foo () {
  return new Promise((resolve, reject) => {
    // ...

    if (err) {
      return reject(err);
    }

    resolve(result);
  });
}

类似的东西。现在这既麻烦又烦人,因此将函数标记为 async 允许编写更简单,并让编译器添加 new Promise 包装器:

async function foo () {
  // ...

  if (err) {
    throw err;
  }

  return result;
}

尽管我们现在可以使用 throwreturn,但幕后发生的事情与以前完全相同:编译器添加了一个 return new Promise 包装器,并为每个 return,它调用 resolve,对于每个 throw,它调用 reject

你可以很容易地看到这实际上和以前一样,因为你可以用 async 定义一个函数,但是然后从外部调用 if 而没有 await,通过使用承诺的良好旧 then 语法:

foo().then(...);

反之亦然:如果函数是使用 new Promise 包装器定义的,则可以 await 它。所以,长话短说,asyncawait 只是简洁的语法,可以用来做一些你需要手动做的事情。

这反过来意味着,即使您使用 async 定义了一个函数,也绝对没有 保证 它实际上已被 [=14= 调用过]!如果缺少 await,这并不一定意味着它是一个错误——也许有人只是更喜欢 then 语法。

因此,总而言之,即使您的问题有技术解决方案,也无济于事,至少不是在所有情况下,因为您不需要使用 await 调用 async 函数而不牺牲异步性。

我知道在您的场景中您希望确保真正等待承诺,但恕我直言,您随后花费大量时间来构建一个复杂的解决方案,但并没有解决可能出现的所有问题在那里。所以,从我个人的角度来看,这是不值得的。

术语说明:您实质上要问的是检测 "floating promises"。这包含创建浮动承诺的代码:

methodA() {
   this.methodB()
       .then(() => true); // .then() returns a promise that is lost
},

这也是:

async methodA() {
   // The promise returned by this.methodB() is not chained with the one
   // returned by methodA.
   this.methodB();
   return true; 
},

在第一种情况下,您将添加 return 以允许调用者链接承诺。在第二种情况下,您将使用 awaitthis.methodB() 返回的承诺链接到 methodA.

返回的承诺

使处理浮动承诺的目标变得复杂的一件事是,有时开发人员有充分的理由让承诺浮动。所以任何检测方法都需要提供一种方式来表示"this floating promise is okay".

您可以使用几种方法。

使用类型分析

如果您使用提供静态类型检查的工具,您可以在 运行 代码之前捕获浮动承诺。

我知道你绝对可以将 TypeScript 与 tslint 结合使用,因为我有这方面的经验。 TypeScript 编译器提供类型信息,如果你将 tslint 设置为 运行 no-floating-promises 规则,那么 tslint 将使用类型信息来检测两个中的浮动承诺以上案例

TypeScript 编译器可以对纯 JS 文件进行类型分析,因此理论上您的代码库可以保持不变,您只需要使用配置来配置 TypeScript 编译器像这样:

{
  "compilerOptions": {
    "allowJs": true, // To allow JS files to be fed to the compiler.
    "checkJs": true, // To actually turn on type-checking.
    "lib": ["es6"] // You need this to get the declaration of the Promise constructor.
  },
  "include": [
    "*.js", // By default JS files are not included.
    "*.ts" // Since we provide an explicit "include", we need to state this too.
  ]
}

"include" 中的路径需要根据您的特定项目布局进行调整。 tslint.json:

你需要这样的东西
{
  "jsRules": {
    "no-floating-promises": true
  }
}

我在上面写了in theory,因为我们所说的 tslint不能在JavaScript上使用类型信息文件,即使 allowJscheckJs 为真。碰巧的是,a tslint issue 关于这个问题,由某人提交(巧合!)碰巧想要 运行 普通 JS 文件的 no-floating-promise 规则。

所以正如我们所说,为了能够从上述检查中受益,您必须将您的代码库设为 TypeScript。

根据我的经验,一旦您拥有 TypeScript 和 tslint 设置 运行ning,这将检测您代码中的所有浮动承诺,并且不会报告虚假案例。即使您有一个希望在代码中保持浮动的承诺,您也可以使用 tslint 指令,例如 // tslint:disable-next-line:no-floating-promises。第三方库是否故意让承诺浮动并不重要:您将 tslint 配置为仅报告代码问题,这样它就不会报告第三方库中存在的问题。

还有其他提供类型分析的系统,但我不熟悉。例如,Flow 也可能有效,但我从未使用过它,所以我不能说它是否有效。

使用在运行时检测浮动承诺的承诺库

在检测 您的 代码中的问题同时忽略 其他地方 的问题时,此方法不如类型分析可靠。

问题是我不知道有哪个 promise 库可以普遍、可靠并同时满足这两个要求:

  1. 检测所有浮动承诺的情况。

  2. 不报告您不关心的案例。 (特别是第三方代码中的浮动承诺。)

根据我的经验,配置 promise 库以改进它处理两个需求之一的方式会损害它处理另一个需求的方式。

我最熟悉的promise库是Bluebird。我能够使用 Bluebird 检测浮动承诺。然而,虽然您可以将 Bluebird 承诺与遵循 Promises/A+ 的框架产生的任何承诺混合,但当您进行这种混合时,您会阻止 Bluebird 检测 some 浮动承诺.您可以通过将默认的 Promise 实现替换为 Bluebird 但

来提高检测所有情况的机会
  1. 明确使用第 3 方实现而非本机实现的库(例如 const Promise = require("some-spiffy-lib"))仍将使用该实现。因此,您可能无法在测试期间获取所有代码 运行ning 以使用 Bluebird。

  2. 并且您最终可能会收到关于浮动承诺的虚假警告,即 在第三方库中故意保持浮动 。 (请记住,有时开发人员会故意 浮动承诺 。)Bluebird 不知道哪些是您的代码,哪些不是。它将报告它能够检测到的所有情况。在您自己的代码中,您可以向 Bluebird 表明您想让 promise 浮动,但在第三方代码中,您必须修改该代码以消除警告。

由于这些问题,我不会使用这种方法来严格检测浮动承诺。

TLDR

如果 methodAmethodB 上调用 await,则 Promise return 由 methodA 编辑将不会解析直到 methodB 编辑的 Promise return 解析 .

另一方面,如果 methodA 没有在 methodB 上调用 await,那么 Promise return 由 methodA 将立即解决由 methodB 编辑的 Promise return 是否已解决 .

所以测试 methodA 是否在 methodB 上调用 await 只是测试 Promise return 是否由 methodA 编辑的问题等待由 methodB 编辑的 Promise return 在它解析之前解析:

const { stub } = require('sinon');
const { expect } = require('chai');

const testObject = {
  async methodA() {
    await this.methodB();
  },
  async methodB() { }
};

describe('methodA', () => {
  const order = [];
  let promiseB;
  let savedResolve;

  beforeEach(() => {
    promiseB = new Promise(resolve => {
      savedResolve = resolve;  // save resolve so we can call it later
    }).then(() => { order.push('B') })
    stub(testObject, 'methodB').returns(promiseB);
  });

  afterEach(() => {
    testObject.methodB.restore();
  });

  it('should await methodB', async () => {
    const promiseA = testObject.methodA().then(() => order.push('A'));
    savedResolve();  // now resolve promiseB
    await Promise.all([promiseA, promiseB]);  // wait for the callbacks in PromiseJobs to complete
    expect(order).to.eql(['B', 'A']);  // SUCCESS: 'B' is first ONLY if promiseA waits for promiseB
  });
});


详情

在所有三个代码示例中,methodAmethodB 都是 return 和 Promise

我将 methodA 编辑的 Promise return 称为 promiseAPromise return 编辑 methodB 作为 promiseB.

您正在测试的是 promiseA 是否等待解决直到 promiseB 解决。


首先,让我们看看如何测试 promiseA 没有等待 promiseB


测试 promiseA 是否不等待 promiseB

测试负面情况(promiseA 没有等待 promiseB)的一种简单方法是模拟 methodB 到 return a Promise 永远不会解析:

describe('methodA', () => {

  beforeEach(() => {
    // stub methodB to return a Promise that never resolves
    stub(testObject, 'methodB').returns(new Promise(() => {}));
  });

  afterEach(() => {
    testObject.methodB.restore();
  });

  it('should NOT await methodB', async () => {
    // passes if promiseA did NOT wait for promiseB
    // times out and fails if promiseA waits for promiseB
    await testObject.methodA();
  });

});

这是一个非常干净、简单、直接的测试。


如果我们能return相反的东西就好了...returntrue如果这个测试失败.

不幸的是,这不是一个合理的方法,因为 此测试超时 如果 promiseA 执行 await promiseB.

我们需要一种不同的方法。


背景信息

在继续之前,这里有一些有用的背景信息:

JavaScript 在下一个开始之前使用 message queue. The current message runs to completion测试是运行ning,测试是当前消息

ES6 引入了处理作业 "that are responses to the settlement of a Promise" 的 PromiseJobs queue。 PromiseJobs 队列中的任何作业 运行 在当前消息完成之后和下一条消息开始之前

因此 Promise 解析时,它的 then 回调被添加到 PromiseJobs 队列,并且当当前消息完成时,PromiseJobs 中的任何作业将运行按顺序直到队列为空。

asyncawait 只是 syntactic sugar over promises and generators。在 Promise 上调用 await 实质上将函数的其余部分包装在回调中,以便在等待的 Promise 解析时在 PromiseJobs 中安排。


我们需要的是一个测试,如果 promiseA DID 等待 promiseB.

,它会在没有超时的情况下告诉我们

由于我们不希望测试超时,promiseApromiseB 都必须 解析。

那么objective就是想办法判断promiseA是否等待promiseB 因为它们都在解析 .

答案是利用 PromiseJobs 队列。

考虑这个测试:

it('should result in [1, 2]', async () => {
  const order = [];
  const promise1 = Promise.resolve().then(() => order.push('1'));
  const promise2 = Promise.resolve().then(() => order.push('2'));
  expect(order).to.eql([]);  // SUCCESS: callbacks are still queued in PromiseJobs
  await Promise.all([promise1, promise2]);  // let the callbacks run
  expect(order).to.eql(['1', '2']);  // SUCCESS
});

Promise.resolve() return 已解决 Promise 因此两个回调会立即添加到 PromiseJobs 队列中。一旦当前消息(测试)暂停以等待 PromiseJobs 中的作业,它们 运行 按照它们被添加到 PromiseJobs 队列的顺序以及当测试在 运行 之后 await Promise.all 继续时] order 数组包含预期的 ['1', '2']

现在考虑这个测试:

it('should result in [2, 1]', async () => {
  const order = [];
  let savedResolve;
  const promise1 = new Promise((resolve) => {
    savedResolve = resolve;  // save resolve so we can call it later
  }).then(() => order.push('1'));
  const promise2 = Promise.resolve().then(() => order.push('2'));
  expect(order).to.eql([]);  // SUCCESS
  savedResolve();  // NOW resolve the first Promise
  await Promise.all([promise1, promise2]);  // let the callbacks run
  expect(order).to.eql(['2', '1']);  // SUCCESS
});

在这种情况下,我们保存第一个 Promise 中的 resolve,以便稍后调用它。 由于第一个 Promise 尚未解析,then 回调不会立即添加到 PromiseJobs 队列 。另一方面,第二个 Promise 已经解析,所以它的 then 回调被添加到 PromiseJobs 队列。一旦发生这种情况,我们将调用已保存的 resolve,以便第一个 Promise 解析,这会将其 then 回调添加到 PromiseJobs 队列的末尾。一旦当前消息(测试)暂停以等待 PromiseJobs 中的作业,order 数组将按预期包含 ['2', '1']


What is the smart way to test if await was used in the function call?

测试函数调用中是否使用了 await 的聪明方法是向 promiseApromiseB 添加一个 then 回调,然后 延迟解析 promiseB。如果 promiseA 等待 promiseB,那么它的回调将 始终是 PromiseJobs 队列中的最后一个 。另一方面,如果 promiseA 不等待 promiseB 那么它的回调将在 PromiseJobs 中排队 first

最终解决方案在上面的 TLDR 部分。

请注意,当 methodA 是一个在 methodB 上调用 awaitasync 函数时,以及当 methodA 是一个return 是 Promise 链接到 methodBPromise return 的正常(不是 async)函数(正如预期的那样,一旦同样,async / await 只是 Promises 和生成器的语法糖)。