我的两分钱是嘲笑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 如何在 imports 之上调用 jest.mock 调用,这些调用正在编译为 CommonJS Jest-hijacked 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 的实现细节也不是一个好主意。这样的测试无法验证算法的正确性,因为它只是验证它所做的调用。如果存在边缘情况错误,则它的覆盖范围不足以击中它。
当我们想要反对对象的通信时,Mocking/Spying 通常是有益的;在通信实际上是正确的断言的情况下,例如"断言它命中了数据库";但这里不是。
一个更好的测试用例的想法可能是“大力”运行 SUT 并断言它对于它正在做的事情“足够好”;选择一个随机元素。这个想法得到了Law of Large Numbers的支持:
“在概率论中,大数定律(LLN)是一个定理,描述了多次执行相同实验的结果。根据该定律,从一个实验中得到的结果的平均值大量的试验应该接近预期值,并且随着更多试验的进行会趋于接近预期值。” — Wikipedia
为 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)
})
})
更易读的测试
当测试像上面那样被“功能噪音”污染时,它们变得难以阅读;它们不再用作文档。
在这种情况下,开发自定义匹配器或测试替身有助于提高可读性。
test.each([
// ...
])('picks the most probable: %p from %p with weights: %p', mostProbables, dataset, weights) => {
const results = []
const operator = new Randomize(...whatever)
;[...Array(420).keys()].forEach(() => results.push(
operator.randomPick(...whatever)
)
expect(results).toHaveMostFrequentElements(mostProbables)
}
这个自定义的toHaveMostFrequentElements 断言匹配器有助于消除测试中的“噪音”。