使用 Jest 在另一个模块的依赖项中断言函数调用

Adr*_*ien 1 javascript unit-testing mocking jestjs

我尝试覆盖以下代码:

\n\n
// @flow strict\n\nimport { bind, randomNumber } from 'Utils'\nimport { AbstractOperator } from './AbstractOperator'\n\nexport class Randomize extends AbstractOperator {\n  // ...\n\n  randomPick (dataset: Array<string>, weights: ?Array<number>): number {\n    if (!weights) { return randomNumber(0, (dataset.length - 1)) }\n\n    const sumOfWeights: number = weights.reduce((a, b) => a + b)\n    let randomWeight = randomNumber(1, sumOfWeights)\n    let position: number = -1\n\n    for (let i = 0; i < dataset.length; i++) {\n      randomWeight = randomWeight - weights[i]\n      if (randomWeight <= 0) {\n        position = i\n        break\n      }\n    }\n\n    return position\n  }\n}\n
Run Code Online (Sandbox Code Playgroud)\n\n

这是测试覆盖率:

\n\n
import { Randomize } from './Randomize'\n\nconst dataset = [\n  'nok',\n  'nok',\n  'nok',\n  'ok',\n  'nok'\n]\n\nconst weights = [\n  0,\n  0,\n  0,\n  1,\n  0\n]\n\nconst randomNumber = jest.fn()\n\ndescribe('operator Randomize#randomPick', () => {\n  test('without weights, it calls `randomNumber`', () => {\n    const randomizeOperator = new Randomize({}, [dataset], {})\n    randomizeOperator.randomPick(dataset)\n\n    expect(randomNumber).toBeCalledWith(0, dataset.length - 1)\n  })\n})\n
Run Code Online (Sandbox Code Playgroud)\n\n

我试图确保它randomNumber被调用,但我得到的只是:

\n\n
  \xe2\x97\x8f operator Randomize#randomPick \xe2\x80\xba without weights, it calls `randomNumber`\n\n    expect(jest.fn()).toBeCalledWith(...expected)\n\n    Expected: 0, 4\n\n    Number of calls: 0\n\n      33 |     randomizeOperator.randomPick(dataset)\n      34 |\n    > 35 |     expect(randomNumber).toBeCalledWith(0, dataset.length - 1)\n         |                          ^\n      36 |   })\n      37 | })\n      38 |\n\n      at Object.toBeCalledWith (node_modules/jest-chain/dist/chain.js:15:11)\n      at Object.toBeCalledWith (src/app/Services/Providers/Result/Resolvers/Operators/Randomize.test.js:35:26)\n
Run Code Online (Sandbox Code Playgroud)\n

sep*_*ehr 5

\n我的两分钱是,模拟randomNumber依赖关系并不是测试此功能的正确方法。

\n\n

不过,我将在这里回答主要问题,看看我们如何才能通过测试。然后将在未来的更新中得到我关于更好的测试方法的额外想法。

\n\n

断言反对randomNumber调用

\n\n

拦截导入和模拟

\n\n

代码的实际问题是randomNumber模拟函数悬而未决。正如错误所示,它没有被调用。

\n\n

缺少的部分是拦截模块导入并使其外部调用能够Utils.randomNumber触发模拟函数;这样我们就可以反对它。以下是如何拦截Utils导入并模拟它:

\n\n
// Signature is: \n// jest.mock(pathToModule: string, mockModuleFactory: Function)\njest.mock(\'Utils\', () => ({\n  randomNumber: jest.fn()\n}))\n
Run Code Online (Sandbox Code Playgroud)\n\n

现在,测试期间的每次调用Utils.randomNumber都会触发模拟函数,并且它不再悬而未决。

\n\n

如果您想了解它在幕后是如何工作的,请研究一下如何在babel-plugin-jest-hoist编译为 CommonJS Jest 劫持调用的jest.mock之上进行提升调用。importrequire

\n\n

根据具体情况,模拟整个模块可能会出现问题。如果测试依赖于Utils模块的其他导出怎么办?例如bind

\n\n

有一些方法可以部分模拟一个模块,只是一个函数,一个或两个类。然而,为了让您的测试通过,还有一种更简单的方法。

\n\n

监视它

\n\n

解决方案就是简单地监听通话randomNumber。这是一个完整的例子:

\n\n
import { Randomize } from \'./Randomize\'\nimport * as Utils from \'Utils\'\n\n// Sidenote: This values should probably be moved to a beforeEach()\n// hook. The module-level assignment does not happen before each test.\nconst weights = [0, 0, 0, 1, 0]\nconst dataset = [\'nok\', \'nok\', \'nok\', \'ok\', \'nok\']\n\ndescribe(\'operator Randomize#randomPick\', () => {\n  test(\'without weights, it calls `randomNumber`\', () => {\n    const randomizeOperator = new Randomize({}, [dataset], {})\n    const randomNumberSpy = jest.spyOn(Utils, \'randomNumber\')\n\n    randomizeOperator.randomPick(dataset)\n\n    expect(randomNumberSpy).toBeCalledWith(0, dataset.length - 1)\n  })\n})\n
Run Code Online (Sandbox Code Playgroud)\n\n

希望这是一项通过测试,但非常脆弱。

\n\n

总结一下,以下是关于笑话背景下该主题的非常好的读物:

\n\n\n\n
\n\n

为什么这不是一个好的测试?

\n\n

主要是因为测试与代码紧密耦合。如果您比较测试和 SUT,就会发现重复的代码。

\n\n

更好的方法是根本不模拟/监视任何东西(研究Classist 与 Mockist TDD学校)并使用一组动态生成的数据和权重来练习 SUT,这反过来又断言它“足够好” 。

\n\n

我将在更新中详细说明这一点。

\n\n
\n\n

更好的测试

\n\n

randomPick出于另一个原因,测试其实现细节也不是一个好主意。此类测试无法验证算法的正确性,因为它仅验证其发出的调用。如果存在边缘情况错误,则它的覆盖范围不足以解决该错误。

\n\n

当我们想要断言对象的通信时,模拟/间谍通常是有益的;在通信实际上断言正确性的情况下,例如“断言它到达了数据库”;但这里不是。

\n\n

更好的测试用例的一个想法可能是“大力”运用 SUT 并断言它对于它正在做的事情来说“足够好”;选择一个随机元素。这个想法得到了大数定律的支持:

\n\n
\n

“在概率论中,大数定律(LLN)是描述多次进行相同实验的结果的定理。根据该定律,从大量试验中获得的结果的平均值应为接近预期值,并且随着进行更多试验,将趋于更接近预期值。” \xe2\x80\x94维基百科

\n
\n\n

为 SUT 提供相对较大的、动态生成的随机输入集,并断言它每次都会通过:

\n\n
import { Randomize } from \'./Randomize\'\n\nconst exercise = (() => {\n  // Dynamically generate a relatively large random set of input & expectations:\n  // [ datasetArray, probabilityWeightsArray, expectedPositionsArray ]\n  //\n  // A sample manual set:  \n  return [\n    [[\'nok\', \'nok\', \'nok\', \'ok\', \'nok\'], [0, 0, 0, 1, 0], [3]],\n    [[\'ok\', \'ok\', \'nok\', \'ok\', \'nok\'], [50, 50, 0, 0, 0], [0, 1]],\n    [[\'nok\', \'nok\', \'nok\', \'ok\', \'ok\'], [0, 0, 10, 60, 30], [2, 3, 4]]\n  ]\n})()\n\ndescribe(\'whatever\', () => {\n  test.each(exercise)(\'look into positional each() params for unique names\', (dataset, weights, expected) => {\n    const randomizeOperator = new Randomize({}, [dataset, weights], {})\n\n    const position = randomizeOperator.randomPick(dataset, weights)\n\n    expect(position).toBeOneOf(expected)\n  })\n})\n
Run Code Online (Sandbox Code Playgroud)\n\n
\n\n

这是基于相同想法的另一个观点,不一定需要生成动态数据:

\n\n
import { Randomize } from \'./Randomize\'\n\nconst exercise = (() => {\n  return [\n    [\n      [\'moreok\'], // expect "moreok" to have been picked more during the exercise.\n      [\'lessok\', \'moreok\'], // the dataset.\n      [0.1, 99.90] // weights, preferring the second element over the first.\n    ],\n    [[\'moreok\'], [\'moreok\', \'lessok\'], [99, 1]],\n    [[\'moreok\'], [\'lessok\', \'moreok\'], [1, 99]],\n    [[\'e\'], [\'a\', \'b\', \'c\', \'d\', \'e\'], [0, 10, 10, 0, 80]],\n    [[\'d\'], [\'a\', \'b\', \'c\', \'d\'], [5, 20, 0, 75]],\n    [[\'d\'], [\'a\', \'d\', \'c\', \'b\'], [5, 75, 0, 20]],\n    [[\'b\'], [\'a\', \'b\', \'c\', \'d\'], [0, 80, 0, 20]],\n    [[\'a\', \'b\'], [\'a\', \'b\', \'c\', \'d\'], [50, 50]],\n    [[\'b\'], [\'a\', \'b\', \'c\'], [10, 60, 30]],\n    [[\'b\'], [\'a\', \'b\', \'c\'], [0.1, 0.6, 0.3]] // This one pinpoints a bug.\n  ]\n})()\n\nconst mostPicked = results => {\n  return Object.keys(results).reduce((a, b) => results[a] > results[b] ? a : b )\n}\n\ndescribe(\'randompick\', () => {\n  test.each(exercise)(\'picks the most probable: %p from %p with weights: %p\', (mostProbables, dataset, weights) => {\n    const operator = new Randomize({}, [dataset, weights], {})\n    const results = dataset.reduce((carry, el) => Object.assign(carry, { [el]: 0 }), {})\n    // e.g. { lessok: 0, moreok: 0 }\n\n    for (let i = 0; i <= 2000; i++) {\n      // count how many times a dataset element has win the lottery!\n      results[dataset[operator.randomPick(dataset, weights)]]++\n    }\n\n    // console.debug(results, mostPicked(results))\n\n    expect(mostPicked(results)).toBeOneOf(mostProbables)\n  })\n})\n
Run Code Online (Sandbox Code Playgroud)\n\n
\n\n

更具可读性的测试

\n\n

当测试被如上所述的“功能噪音”污染时,它们就会变得难以阅读;它们不再充当文档。

\n\n

在这种情况下,开发自定义匹配器或测试替身有助于提高可读性。

\n\n
test.each([\n  // ...\n])(\'picks the most probable: %p from %p with weights: %p\', mostProbables, dataset, weights) => {\n    const results = []\n    const operator = new Randomize(...whatever)\n\n    ;[...Array(420).keys()].forEach(() => results.push(\n      operator.randomPick(...whatever)\n    )\n\n    expect(results).toHaveMostFrequentElements(mostProbables)\n}\n
Run Code Online (Sandbox Code Playgroud)\n\n

这个自定义toHaveMostFrequentElements断言匹配器有助于消除测试中的“噪音”。

\n