testdouble.js

testdouble.js

JavaScript 测试替身库助力测试驱动开发

testdouble.js 是一款专为 JavaScript 测试设计的替身库,支持 Node.js 和浏览器环境,兼容多种测试框架。它提供替换依赖、创建测试替身和配置存根响应等核心功能,致力于帮助开发者编写简洁、清晰的隔离测试。这个库特别适合实践测试驱动开发的团队,能有效提升测试效率和代码质量。

testdouble.js测试替身模拟库单元测试JavaScript测试Github开源项目

testdouble.js(又称 td.js)

npmjs unpkg

欢迎!您是否正在编写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 来提问

API

使用 td.replace()td.replaceEsm() 替换依赖项

测试替身库首先需要提供一种方法,让你能够用测试控制的假对象替换被测试对象的生产依赖项。

我们提供了一个名为 td.replace() 的顶级函数,它有两种不同的工作模式:CommonJS 模块替换和对象属性替换。默认情况下,这两种模式都会对真实依赖项进行深度克隆,并将遇到的所有函数替换为假的测试替身函数。这些函数可以由你的测试配置,用于模拟响应或断言调用。

对于 ES 模块,你应该使用 td.replaceEsm()。更多详情请参阅这里

Node.js 的模块替换

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 模块时,需要注意以下几点重要事项:

  • 测试必须在 before-each 钩子中 td.replace()require() 所有内容,以绕过 Node.js 模块缓存并避免测试之间的污染
  • 传递给 td.replace() 的任何相对路径都是从测试到依赖项的相对路径。这与其他一些工具的做法相反,但我们认为这更有意义
  • 测试套件(通常在全局 after-each 钩子中)必须调用 td.reset() 以确保在每个测试用例之后恢复真实的 require() 函数和依赖模块
ES 模块的默认导出

如果你的模块使用 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 对象,包含两个属性 addsubtract,它们分别被赋值为名为 '.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 对象上的 selectsave 属性被设置为名为 '#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 必须触发事件循环,请记住,任何用 thenResolvethenReject 配置的存根都必须作为异步测试进行管理(如果你不确定,请查阅你的测试框架文档)。

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() 可以是编写快速清晰的同步隔离单元测试的好方法,即使实际生产代码是异步的。另一方面,如果需要验证被测对象在多个事件循环周期内的正确行为,可以使用 deferdelay 选项 来控制这一点。

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() 一样,更复杂的情况可以通过参数匹配器和配置选项来处理。

需要注意的是:td.verify() 应该只在少数情况下使用。当你验证一个函数被调用(而不是依赖它的返回值)时,你是在断言你的主体有一个副作用。有大量副作用的代码是不好的,因此模拟库常常被滥用来使具有大量副作用的代码更容易扩散。在这些情况下,将每个依赖项重构为返回值几乎总是更好的设计方法。验证调用的另一个测试异味是,有时为了最大程度的完整性,测试会验证一个已经满足存根的调用,但这几乎是可以证明是不必要的。

td.listReplacedModules() 用于列出被替换的模块

td.listReplacedModules()

使用 td.listReplacedModules() 来列出被替换的模块。此函数将返回一个数组,包含当前通过 td.replace()td.replaceEsm() 被替换的模块。

列表没有特定顺序,返回的是被替换模块的完整路径。

路径以 file: URL 的形式返回,这是 ESM 中的惯例(即使被替换的模块是 CJS,也是如此)。

例如,如果你执行以下操作:

td.replace('../src/save')

那么

td.listReplacedModules()

将返回类似这样的结果:

['file:///users/example/code/foo/src/save.js']

其他功能

关于testdouble.js API中其他顶级功能,请参阅docs目录:

编辑推荐精选

Keevx

Keevx

AI数字人视频创作平台

Keevx 一款开箱即用的AI数字人视频创作平台,广泛适用于电商广告、企业培训与社媒宣传,让全球企业与个人创作者无需拍摄剪辑,就能快速生成多语言、高质量的专业视频。

即梦AI

即梦AI

一站式AI创作平台

提供 AI 驱动的图片、视频生成及数字人等功能,助力创意创作

扣子-AI办公

扣子-AI办公

AI办公助手,复杂任务高效处理

AI办公助手,复杂任务高效处理。办公效率低?扣子空间AI助手支持播客生成、PPT制作、网页开发及报告写作,覆盖科研、商业、舆情等领域的专家Agent 7x24小时响应,生活工作无缝切换,提升50%效率!

TRAE编程

TRAE编程

AI辅助编程,代码自动修复

Trae是一种自适应的集成开发环境(IDE),通过自动化和多元协作改变开发流程。利用Trae,团队能够更快速、精确地编写和部署代码,从而提高编程效率和项目交付速度。Trae具备上下文感知和代码自动完成功能,是提升开发效率的理想工具。

AI工具TraeAI IDE协作生产力转型热门
蛙蛙写作

蛙蛙写作

AI小说写作助手,一站式润色、改写、扩写

蛙蛙写作—国内先进的AI写作平台,涵盖小说、学术、社交媒体等多场景。提供续写、改写、润色等功能,助力创作者高效优化写作流程。界面简洁,功能全面,适合各类写作者提升内容品质和工作效率。

AI辅助写作AI工具蛙蛙写作AI写作工具学术助手办公助手营销助手AI助手
问小白

问小白

全能AI智能助手,随时解答生活与工作的多样问题

问小白,由元石科技研发的AI智能助手,快速准确地解答各种生活和工作问题,包括但不限于搜索、规划和社交互动,帮助用户在日常生活中提高效率,轻松管理个人事务。

热门AI助手AI对话AI工具聊天机器人
Transly

Transly

实时语音翻译/同声传译工具

Transly是一个多场景的AI大语言模型驱动的同声传译、专业翻译助手,它拥有超精准的音频识别翻译能力,几乎零延迟的使用体验和支持多国语言可以让你带它走遍全球,无论你是留学生、商务人士、韩剧美剧爱好者,还是出国游玩、多国会议、跨国追星等等,都可以满足你所有需要同传的场景需求,线上线下通用,扫除语言障碍,让全世界的语言交流不再有国界。

讯飞智文

讯飞智文

一键生成PPT和Word,让学习生活更轻松

讯飞智文是一个利用 AI 技术的项目,能够帮助用户生成 PPT 以及各类文档。无论是商业领域的市场分析报告、年度目标制定,还是学生群体的职业生涯规划、实习避坑指南,亦或是活动策划、旅游攻略等内容,它都能提供支持,帮助用户精准表达,轻松呈现各种信息。

AI办公办公工具AI工具讯飞智文AI在线生成PPTAI撰写助手多语种文档生成AI自动配图热门
讯飞星火

讯飞星火

深度推理能力全新升级,全面对标OpenAI o1

科大讯飞的星火大模型,支持语言理解、知识问答和文本创作等多功能,适用于多种文件和业务场景,提升办公和日常生活的效率。讯飞星火是一个提供丰富智能服务的平台,涵盖科技资讯、图像创作、写作辅助、编程解答、科研文献解读等功能,能为不同需求的用户提供便捷高效的帮助,助力用户轻松获取信息、解决问题,满足多样化使用场景。

热门AI开发模型训练AI工具讯飞星火大模型智能问答内容创作多语种支持智慧生活
Spark-TTS

Spark-TTS

一种基于大语言模型的高效单流解耦语音令牌文本到语音合成模型

Spark-TTS 是一个基于 PyTorch 的开源文本到语音合成项目,由多个知名机构联合参与。该项目提供了高效的 LLM(大语言模型)驱动的语音合成方案,支持语音克隆和语音创建功能,可通过命令行界面(CLI)和 Web UI 两种方式使用。用户可以根据需求调整语音的性别、音高、速度等参数,生成高质量的语音。该项目适用于多种场景,如有声读物制作、智能语音助手开发等。

下拉加载更多