欢迎!您是否正在编写JavaScript测试,并且正在寻找一个模拟库来为您伪造真实对象?testdouble.js是一个有主见的、精心设计的测试替身库,由一个同样名为Test Double的软件代理公司维护。("测试替身"这个术语是由Gerard Meszaros在他的书xUnit测试模式中首次提出的。)
如果您实践测试驱动开发,testdouble.js的设计旨在促进简洁、清晰且易于理解的测试。这里有很多内容需要涵盖,所以请花些时间阅读我们的文档,它旨在向您展示如何在测试中充分利用测试替身。
这个库被设计为同时适用于Node.js和浏览器解释器。它也与测试框架无关,因此您可以将它直接应用到使用Jasmine、Mocha、Tape、Jest或我们自己的teenytest的代码库中。
$ npm install -D testdouble
如果你只想获取浏览器版本,也可以从unpkg使用curl下载。
我们建议在测试辅助文件中引入该库,并为方便起见将其设置为全局变量,使用简写 td
:
// ES 导入语法 import * as td from 'testdouble' // CommonJS 模块(如 Node.js) globalThis.td = require('testdouble') // 在浏览器版本中设置为全局变量 window.td
(你可能需要配置你的代码检查工具以忽略 td
全局变量。
操作说明:
eslint,
standard。)
如果你正在将 testdouble.js 与其他测试框架一起使用,你可能还想查看以下这些扩展:
模拟库常常被滥用而非有效使用,因此如何编写模拟库的文档以仅鼓励健康使用已成为一个真正的挑战。以下是我们为您准备的几种入门 testdouble.js 的方法:
当然,如果您不确定如何使用 testdouble.js 编写隔离测试,我们欢迎您在 GitHub 上开一个 issue 来提问。
td.replace()
和 td.replaceEsm()
替换依赖项测试替身库首先需要提供一种方法,让你能够用测试控制的假对象替换被测试对象的生产依赖项。
我们提供了一个名为 td.replace()
的顶级函数,它有两种不同的工作模式:CommonJS 模块替换和对象属性替换。默认情况下,这两种模式都会对真实依赖项进行深度克隆,并将遇到的所有函数替换为假的测试替身函数。这些函数可以由你的测试配置,用于模拟响应或断言调用。
对于 ES 模块,你应该使用 td.replaceEsm()
。更多详情请参阅这里。
td.replace('../path/to/module'[, customReplacement])
如果你使用 Node.js,并且不介意在测试中使用 CommonJS 的 require()
函数(你仍然可以在生产代码中使用 import
/export
,假设你将其编译为测试可用的形式),testdouble.js 使用我们编写的名为 quibble 的库来修改 require()
函数,使你的被测试对象自动接收到你伪造的依赖项。如果你使用过类似 proxyquire 的工具,这种方法可能会让你感到熟悉,但我们的重点是实现更简洁的测试设置。
以下是在 Node.js 测试的设置中使用 td.replace()
的示例:
let loadsPurchases, generatesInvoice, sendsInvoice, subject module.exports = { beforeEach: () => { loadsPurchases = td.replace('../src/loads-purchases') generatesInvoice = td.replace('../src/generates-invoice') sendsInvoice = td.replace('../src/sends-invoice') subject = require('../src/index') }, //… afterEach: function () { td.reset() } }
在上面的例子中,当 src/index
被 require 时,模块缓存将被绕过。如果 index
随后 require 了任何被 td.replace()
替换的依赖项,它将收到与测试中返回的相同的假依赖项的引用。
由于 td.replace()
首先加载实际文件,它会尽力返回一个与真实对象形状完全相同的假对象。这意味着如果 loads-purchases
导出一个函数,将创建并返回一个测试替身函数。如果 generates-invoice
导出一个构造函数,将返回一个构造函数测试替身,包括所有原始静态函数和实例方法的测试替身。如果 sends-invoice
导出一个包含函数属性的普通对象,将返回一个对象,其中函数属性被替换为测试替身函数。在所有情况下,任何非函数属性都将被深度克隆。
使用 td.replace()
替换 Node.js 模块时,需要注意以下几点重要事项:
td.replace()
和 require()
所有内容,以绕过 Node.js 模块缓存并避免测试之间的污染td.replace()
的任何相对路径都是从测试到依赖项的相对路径。这与其他一些工具的做法相反,但我们认为这更有意义td.reset()
以确保在每个测试用例之后恢复真实的 require()
函数和依赖模块如果你的模块使用 ES 模块语法编写,并且指定了默认导出(例如 export default function loadsPurchases()
),但实际上被转译为 CommonJS,请记住在转换为 CJS 模块格式时需要引用 .default
。
这意味着,不要这样写:
loadsPurchases = td.replace('../src/loads-purchases')
你可能想要这样分配假对象:
loadsPurchases = td.replace('../src/loads-purchases').default
td.replace(containingObject, nameOfPropertyToReplace[, customReplacement])
如果你在 Node.js 环境之外运行测试,或者手动注入依赖项(或使用像 dependable 这样的依赖注入工具),你仍然可以使用 td.replace
来自动替换可作为对象属性引用的内容。
为了说明这一点,假设我们的被测试对象依赖于下面的 app.signup
:
app.signup = { onSubmit: function () {}, onCancel: function () {} }
如果我们的目标是在测试 app.user.create()
时替换 app.signup
,我们的测试设置可能如下所示:
let signup, subject module.exports = { beforeEach: function () { signup = td.replace(app, 'signup') subject = app.user }, // … afterEach: function () { td.reset() } }
td.replace()
总是会返回新创建的假仿制品,尽管在这种情况下,测试和被测试对象显然仍然可以通过 app.signup
引用它。如果出于某种原因我们只想替换 onCancel
函数(尽管在这种情况下,这会让人觉得像是部分模拟),我们可以改为调用 td.replace(app.signup, 'onCancel')
。
记得在 after-each 钩子中调用 td.reset()
(最好是全局调用,这样就不必在每个测试中都记得这样做),以便 testdouble.js 可以恢复原始对象。这对于避免难以调试的测试污染至关重要!
库的仿制功能相当复杂,但并不完美。对于大型、复杂的对象,它也会比较慢。如果你想精确指定用什么来替换真实依赖项,你可以在上述两种模式中通过提供一个可选的最终参数来实现。
替换 Node.js 模块时:
generatesInvoice = td.replace('../generates-invoice', { generate: td.func('a generate function'), name: 'fake invoices' })
替换属性时:
signup = td.replace(app, 'signup', { onSubmit: td.func('fake submit handler'), onCancel: function () { throw Error('do not call me') } })
td.func()
、td.object()
、td.constructor()
、td.instance()
和 td.imitate()
用于创建测试替身虽然 td.replace()
的模仿和注入便利性在项目构建配置允许的情况下很棒,但在许多情况下,你可能想要或需要直接控制创建假对象。每个创建函数既可以模仿真实对象,也可以通过传递一些配置来指定。每个 测试替身创建函数都非常灵活,可以接受各种输入。返回的内容通常取决于传入的配置参数的数量和类型,因此我们将分别用示例调用来突出每种支持的用法:
td.func()
td.func()
函数(也可用作 td.function()
)返回一个测试替身函数,可以通过三种模式调用:
td.func(someRealFunction)
- 返回一个与原函数同名的测试替身函数,包括对其所有自定义属性的深度模仿td.func()
- 返回一个匿名测试替身函数,可用于存根和验证对它的任何调用,但其错误消息和调试输出不会有名称可追溯td.func('some name')
- 返回一个名为 'some name'
的测试替身函数,该名称将出现在任何错误消息中,以及通过将返回的测试替身传入 td.explain() 得到的调试信息中td.func<Type>()
- 返回一个模仿传入类型的测试替身函数。示例和更多细节可以在 使用 TypeScript 中找到td.object()
td.object()
函数返回一个包含测试替身函数的对象,支持三种调用类型:
td.object(realObject)
- 返回传入对象的深度模仿,其中每个函数都被替换为一个以属性路径命名的测试替身函数(例如,如果 realObject.invoices.send()
是一个函数,返回的对象会将属性 invoices.send
设置为一个名为 '.invoices.send'
的测试替身)td.object(['add', 'subtract'])
- 返回一个普通的 JavaScript 对象,包含两个属性 add
和 subtract
,它们分别被赋值为名为 '.add'
和 '.subtract'
的测试替身函数td.object('a Person'[, {excludeMethods: ['then']})
- 当不带参数或第一个参数为字符串名称时,返回一个 ES Proxy。该代理将自动拦截对它的任何调用,并插入一个可用于存根或验证的测试替身。更多详情可以在 我们的完整文档 中找到td.object<Interface>()
- 返回一个对象,其方法作为测试替身暴露,并按照传入的接口进行类型化。示例和更多细节可以在 使用 TypeScript 中找到td.constructor()
如果你的代码依赖于 ES 类或意图通过 new
调用的函数,那么 td.constructor()
函数也可以替换这些依赖。
td.constructor(RealConstructor)
- 返回一个构造函数,其调用可以被验证,并且其静态和 prototype
函数都已被替换为测试替身函数,使用与 td.func(realFunction)
和 td.object(realObject)
相同的模仿机制td.constructor(['select', 'save'])
- 返回一个构造函数,其 prototype
对象上的 select
和 save
属性被设置为名为 '#select'
和 '#save'
的测试替身函数在替换构造函数时,测试通常会通过直接寻址其原型函数来配置存根和验证。为了说明这一点,这意味着在你的测试中你可能会这样写:
const FakeConstructor = td.constructor(RealConstructor) td.when(FakeConstructor.prototype.doStuff()).thenReturn('ok') subject(FakeConstructor)
这样在你的生产代码中你可以:
const subject = function (SomeConstructor) { const thing = new SomeConstructor() return thing.doStuff() // 返回 "ok" }
td.instance()
作为一种简便方法,td.instance()
函数将调用 td.constructor()
并返回它返回的假构造函数的 new
实例。
以下代码片段在功能上是等价的:
const fakeObject = td.instance(RealConstructor)
const FakeConstructor = td.constructor(RealConstructor) const fakeObject = new FakeConstructor()
td.imitate()
td.imitate(realThing[, name])
如果你知道你想模仿某物,但不知道(或不关心)它是函数、对象还是构造函数,你也可以直接将它传递给 td.imitate()
,并带有一个可选的名称参数。
td.when()
用于存根响应td.when(__rehearsal__[, options])
一旦你用测试替身函数替换了被测对象的依赖项,你就会想要能够存根返回值(以及其他类型的响应),以便在被测对象以测试预期的方式调用测试替身时使用。为了使存根配置易于阅读和搜索,td.when()
的第一个参数实际上不是一个参数,而是一个占位符,用于演示你期望被测对象如何调用测试替身,如下所示:
const increment = td.func() td.when(increment(5)).thenReturn(6)
我们称 increment(5)
为"演练调用"。请注意,默认情况下,只有当被测对象完全按照演练的方式调用测试替身时,存根才会被满足。这可以通过参数匹配器进行自定义,允许进行像 increment(td.matchers.isA(Number))
或 save(td.matchers.contains({age: 21}))
这样的演练。
还要注意,td.when()
接受一个可选的配置对象作为第二个参数,这能够实现高级用法,如忽略多余的参数和限制存根可以被满足的次数。
调用 td.when()
会返回一些函数,允许你指定当测试替身按照你的演练被调用时想要的结果。我们将从最常见的 thenReturn
开始。
td.when().thenReturn()
td.when(__rehearsal__[, options]).thenReturn('某个值'[, 更多, 值])
最简单的例子是当你想为已知参数返回一个特定值时,如下所示:
const loadsPurchases = td.replace('../src/loads-purchases') td.when(loadsPurchases(2018, 8)).thenReturn(['一次购买', '另一次'])
然后,在被测对象的使用中:
loadsPurchases(2018, 8) // 返回 `['一次购买', '另一次']` loadsPurchases(2018, 7) // 返回 undefined,因为没有满足的存根
如果你不习惯存根,可能会觉得测试知道要传入和期望从依赖项返回的确切参数有些刻意,但在孤立的单元测试中,这不仅是可行的,而且是完全正常和预期的!这样做有助于确保测试保持最小化,并对未来的读者来说显而易见。
还要注意,可以通过向 thenReturn()
传递额外的参数来存根后续匹配的调用,如下所示:
const hitCounter = td.func() td.when(hitCounter()).thenReturn(1, 2, 3, 4) hitCounter() // 1 hitCounter() // 2 hitCounter() // 3 hitCounter() // 4 hitCounter() // 4
td.when().thenResolve()
和 td.when().thenReject()
td.when(__rehearsal__[, options]).thenResolve('某个值'[, 更多, 值])
td.when(__rehearsal__[, options]).thenReject('某个值'[, 更多, 值])
thenResolve()
和 thenReject()
存根会将传递给它们的任何值包装在一个立即解决或拒绝的 Promise 中。默认情况下,testdouble.js 将使用全局定义的 Promise
,但你可以指定自己的 Promise,如下所示:
td.config({promiseConstructor: require('bluebird')})
因为 Promise 规范指出所有 Promise 必须触发事件循环,请记住,任何用 thenResolve
或 thenReject
配置的存根都必须作为异步测试进行管理(如果你不确定,请查阅你的测试框架文档)。
td.when().thenCallback()
td.when(__rehearsal__[, options]).thenCallback('某个值'[,其他,参数])
thenCallback()
存根会假设演练的调用有一个额外的最后参数,该参数接受一个回调函数。当这个存根被满足时,testdouble.js 将调用该回调函数并传入传递给 thenCallback()
的任何参数。
为了说明,考虑这个存根:
const readFile = td.replace('../src/read-file') td.when(readFile('my-secret-doc.txt')).thenCallback(null, '秘密!')
然后,被测对象可能会调用 readFile 并传入一个匿名函数:
readFile('my-secret-doc.txt', function (err, contents) { console.log(contents) // 将打印 '秘密!' })
如果回调不在最后位置,或者测试替身也需要返回某些内容,可以使用 td.callback 参数匹配器来配置回调。
一方面,thenCallback()
可以是编写快速清晰的同步隔离单元测试的好方法,即使实际生产代码是异步的。另一方面,如果需要验证被测对象在多个事件循环周期内的正确行为,可以使用 defer
和 delay
选项 来控制这一点。
td.when().thenThrow()
td.when(__rehearsal__[, options]).thenThrow(new Error('炸了'))
thenThrow()
函数完全按照其名称所示工作。一旦配置了这个存根,任何匹配的调用都会抛出指定的错误。
请注意,由于演练调用会调用测试替身函数,因此可能会在配置 thenThrow
存根时意外触发它,然后在尝试配置后续存根或验证时再次触发。在这些情况下,你需要通过重新排序配置或捕获错误来解决这个问题。
td.when().thenDo()
td.when(__rehearsal__[, options]).thenDo(function (arg1, arg2) {})
对于其他所有情况,我们有 thenDo()
。thenDo
接受一个函数,该函数将在存根被满足时调用,并传入所有参数,且绑定到测试替身函数实际被调用时的相同 this
上下文。你的 thenDo
函数返回的任何内容都将在存根被满足时由测试替身返回。这种配置对于处理其他方法无法处理的棘手情况很有用,可能是构建库存根功能的潜在扩展点。
td.verify()
用于验证交互td.verify(__demonstration__[, options])
如果你已经学会了如何使用 td.when()
来存根响应,那么你就已经知道如何使用 td.verify()
来验证调用是否发生了!我们特意让这两者尽可能保持对称。你会发现它们有相同的函数签名,支持相同的参数匹配器,并接受相同的选项。
那么,它们的区别在于用途。存根是为了促进我们想要在主体中执行的某些行为,而验证是为了确保依赖项以特定的预期方式被调用。由于 td.verify()
是一个断言步骤,它应该放在测试的末尾,在我们调用被测主体之后。
一个简单的例子可能是:
module.exports = function shouldSaveThings () { const save = td.replace('../src/save') const subject = require('../src/index') subject({name: 'dataz', data: '010101'}) td.verify(save('dataz', '010101')) }
上面的代码将验证 save
是否使用了指定的两个参数进行调用。如果验证失败(比如传递了 '010100' 而不是 '010101'),testdouble.js 将抛出一个详细的错误消息,解释测试替身函数实际上是如何被调用的,希望能帮助你发现错误。
就像 td.when()
一样,更复杂的情况可以通过参数匹配器和配置选项来处理。