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

时间:2019-09-19 10:31:57

标签: javascript unit-testing mocking jestjs

我尝试覆盖以下代码:

// @flow strict

import { bind, randomNumber } from 'Utils'
import { AbstractOperator } from './AbstractOperator'

export class Randomize extends AbstractOperator {
  // ...

  randomPick (dataset: Array<string>, weights: ?Array<number>): number {
    if (!weights) { return randomNumber(0, (dataset.length - 1)) }

    const sumOfWeights: number = weights.reduce((a, b) => a + b)
    let randomWeight = randomNumber(1, sumOfWeights)
    let position: number = -1

    for (let i = 0; i < dataset.length; i++) {
      randomWeight = randomWeight - weights[i]
      if (randomWeight <= 0) {
        position = i
        break
      }
    }

    return position
  }
}

这是测试范围:

import { Randomize } from './Randomize'

const dataset = [
  'nok',
  'nok',
  'nok',
  'ok',
  'nok'
]

const weights = [
  0,
  0,
  0,
  1,
  0
]

const randomNumber = jest.fn()

describe('operator Randomize#randomPick', () => {
  test('without weights, it calls `randomNumber`', () => {
    const randomizeOperator = new Randomize({}, [dataset], {})
    randomizeOperator.randomPick(dataset)

    expect(randomNumber).toBeCalledWith(0, dataset.length - 1)
  })
})

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

  ● operator Randomize#randomPick › without weights, it calls `randomNumber`

    expect(jest.fn()).toBeCalledWith(...expected)

    Expected: 0, 4

    Number of calls: 0

      33 |     randomizeOperator.randomPick(dataset)
      34 |
    > 35 |     expect(randomNumber).toBeCalledWith(0, dataset.length - 1)
         |                          ^
      36 |   })
      37 | })
      38 |

      at Object.toBeCalledWith (node_modules/jest-chain/dist/chain.js:15:11)
      at Object.toBeCalledWith (src/app/Services/Providers/Result/Resolvers/Operators/Randomize.test.js:35:26)

1 个答案:

答案 0 :(得分:2)

我的两分钱是嘲笑randomNumber依赖关系不是测试此功能的正确方法。

但是,我将在这里回答主要问题,并看看我们如何使测试通过。然后,我将获得更多关于在以后的更新中进行测试的更好方法的想法。

针对randomNumber通话断言

拦截导入和模拟

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

缺少的部分是拦截模块导入并使之导入,使得对Utils.randomNumber的外部调用触发了模拟功能;这样我们就可以反对它。拦截Utils导入并模拟它的方法如下:

// Signature is: 
// jest.mock(pathToModule: string, mockModuleFactory: Function)
jest.mock('Utils', () => ({
  randomNumber: jest.fn()
}))

现在,在测试过程中对Utils.randomNumber的每次调用都会触发模拟功能,并且不再悬而未决。

如果您想知道它在幕后如何工作,请查看babel-plugin-jest-hoist的提升机jest.mockimport之上的调用方式,这些被编译为CommonJS Jest-劫持require个呼叫。

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

有一些方法可以部分模拟一个模块,一个函数,一个或两个类。但是,要使您的测试通过,还有一种更简单的方法。

间谍

解决方案是仅监视randomNumber调用。这是一个完整的示例:

import { Randomize } from './Randomize'
import * as Utils from 'Utils'

// Sidenote: This values should probably be moved to a beforeEach()
// hook. The module-level assignment does not happen before each test.
const weights = [0, 0, 0, 1, 0]
const dataset = ['nok', 'nok', 'nok', 'ok', 'nok']

describe('operator Randomize#randomPick', () => {
  test('without weights, it calls `randomNumber`', () => {
    const randomizeOperator = new Randomize({}, [dataset], {})
    const randomNumberSpy = jest.spyOn(Utils, 'randomNumber')

    randomizeOperator.randomPick(dataset)

    expect(randomNumberSpy).toBeCalledWith(0, dataset.length - 1)
  })
})

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

总结起来,这些是在开玩笑的背景下对主题的很好阅读:


为什么测试不好?

主要是因为测试与代码紧密耦合。如果您将测试和SUT进行比较,就可以看到重复的代码。

一种更好的方法是根本不对任何东西进行模拟/间谍(查看Classist vs. Mockist TDD学校),并使用动态生成的一组数据和权重对SUT进行测试,从而证明它“足够好” 。

我将在更新中对此进行详细说明。


更好的测试

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

当我们想断言对象之间的通信时,模拟/间谍通常是有益的。如果通讯实际上是正确性的主张,例如“断言它已命中数据库” ;但事实并非如此。

一个更好的测试用例的想法可能是大力行使SUT并断言它在做什么方面“足够好”。

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

import { Randomize } from './Randomize'

const exercise = (() => {
  // Dynamically generate a relatively large random set of input & expectations:
  // [ datasetArray, probabilityWeightsArray, expectedPositionsArray ]
  //
  // A sample manual set:  
  return [
    [['nok', 'nok', 'nok', 'ok', 'nok'], [0, 0, 0, 1, 0], [3]],
    [['ok', 'ok', 'nok', 'ok', 'nok'], [50, 50, 0, 0, 0], [0, 1]],
    [['nok', 'nok', 'nok', 'ok', 'ok'], [0, 0, 10, 60, 30], [2, 3, 4]]
  ]
})()

describe('whatever', () => {
  test.each(exercise)('look into positional each() params for unique names', (dataset, weights, expected) => {
    const randomizeOperator = new Randomize({}, [dataset, weights], {})

    const position = randomizeOperator.randomPick(dataset, weights)

    expect(position).toBeOneOf(expected)
  })
})

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

import { Randomize } from './Randomize'

const exercise = (() => {
  return [
    [
      ['moreok'], // expect "moreok" to have been picked more during the exercise.
      ['lessok', 'moreok'], // the dataset.
      [0.1, 99.90] // weights, preferring the second element over the first.
    ],
    [['moreok'], ['moreok', 'lessok'], [99, 1]],
    [['moreok'], ['lessok', 'moreok'], [1, 99]],
    [['e'], ['a', 'b', 'c', 'd', 'e'], [0, 10, 10, 0, 80]],
    [['d'], ['a', 'b', 'c', 'd'], [5, 20, 0, 75]],
    [['d'], ['a', 'd', 'c', 'b'], [5, 75, 0, 20]],
    [['b'], ['a', 'b', 'c', 'd'], [0, 80, 0, 20]],
    [['a', 'b'], ['a', 'b', 'c', 'd'], [50, 50]],
    [['b'], ['a', 'b', 'c'], [10, 60, 30]],
    [['b'], ['a', 'b', 'c'], [0.1, 0.6, 0.3]] // This one pinpoints a bug.
  ]
})()

const mostPicked = results => {
  return Object.keys(results).reduce((a, b) => results[a] > results[b] ? a : b )
}

describe('randompick', () => {
  test.each(exercise)('picks the most probable: %p from %p with weights: %p', (mostProbables, dataset, weights) => {
    const operator = new Randomize({}, [dataset, weights], {})
    const results = dataset.reduce((carry, el) => Object.assign(carry, { [el]: 0 }), {})
    // e.g. { lessok: 0, moreok: 0 }

    for (let i = 0; i <= 2000; i++) {
      // count how many times a dataset element has win the lottery!
      results[dataset[operator.randomPick(dataset, weights)]]++
    }

    // console.debug(results, mostPicked(results))

    expect(mostPicked(results)).toBeOneOf(mostProbables)
  })
})