Sinon `stub.callThroughWithNew()`:让 Stub 以 `new` 运算符调用原始构造函数
Sinon stub.callThroughWithNew():让 Stub 以 new 运算符调用原始构造函数
导读
stub.callThroughWithNew() 是 Sinon Stub API 中用于"穿透调用"(call through)的关键方法:当 stub 上的条件行为(如 withArgs() 配置的分支)均未命中时,它会把调用转发给被包装的原始方法,并且以 new 运算符的方式实例化。本文围绕该方法讲解其用法、与 stub.callsThrough() 的区别、条件 stub 组合逻辑,并结合 Sinon 源码(behavior.js、default-behaviors.js)剖析其底层实现原理。读完本文,你将能够在测试中精确控制"哪些参数走自定义分支、哪些参数真实走构造函数"的混合行为。
一、方法语义:什么是 call-through-with-new
在 Stub API 文档 中,stub.callThroughWithNew() 的定义是:
Causes the original method wrapped into the stub to be called using the
newoperator, when none of the conditional stubs are matched.
翻译过来包含三个关键限定:
- 对象是"被包装的原始方法"(wrapped method)——它只对
sinon.stub(object, "method")这类"包装既有函数"的 stub 有意义,对匿名 stub 无效; - 调用方式是
new——即把原始方法当作构造函数来实例化,而不是普通函数调用; - 触发时机是"条件 stub 均未匹配"——一旦某个
withArgs()分支命中,就执行该分支的自定义行为,不穿透。
从 CHANGES.md 的变更记录看,callThroughWithNew 是在项目演进中单独新增的方法(记录为 "add callThroughWithNew method"),它的出现正是为了补齐"穿透调用构造函数"这一场景——普通的 callsThrough() 无法满足以 new 方式调用原始构造函数的测试需求。
二、基本用法:完整可运行的示例
文档正文在 call-through-with-new.md 中通过嵌入测试用例(call-through-with-new.test.js)来示范用法,下面展开这份测试代码并逐步解释:
import tap from "tap";
import * as sinon from "sinon";
tap.test("stub.callThroughWithNew - basic usage", (t) => {
const obj = {};
obj.Sum = function MyConstructor(a, b) {
this.result = a + b;
};
sinon
.stub(obj, "Sum")
.callThroughWithNew()
.withArgs(1, 2)
.returns({ result: 9000 });
const sum1 = new obj.Sum(2, 2);
t.equal(sum1.result, 4, "calls through to original constructor");
const sum2 = new obj.Sum(1, 2);
t.equal(sum2.result, 9000, "returns custom value for matching args");
obj.Sum.restore();
t.end();
});
这个示例完整展示了该方法的典型应用模式:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 准备原始构造函数 | obj.Sum = function MyConstructor(a, b) { this.result = a + b; } |
被 stub 包装的原型,必须是可实例化的函数 |
| 创建 stub 并开启穿透 | sinon.stub(obj, "Sum") + .callThroughWithNew() |
替换 obj.Sum,并声明"未命中分支时用 new 调原始构造函数" |
| 配置条件分支 | .withArgs(1, 2).returns({ result: 9000 }) |
仅当实参为 (1, 2) 时返回自定义对象 |
| 未命中分支 | new obj.Sum(2, 2) |
真实执行 MyConstructor,sum1.result === 4 |
| 命中分支 | new obj.Sum(1, 2) |
返回 { result: 9000 },构造函数未被真正调用 |
| 还原 | obj.Sum.restore() |
恢复 obj.Sum 为原始构造函数 |
注意两点实战细节:
- 链式调用顺序:
callThroughWithNew()与withArgs()在链式 API 上可以任意先后组合,Sinon 的行为解析以"调用时实参匹配"为准(详见下文"条件 stub 与穿透的协作"一节)。 new的语义被保留:即使穿透调用,返回值依然是new出来的实例对象,this指向新实例,与直接new MyConstructor(2, 2)完全一致。
三、与 stub.callsThrough() 的核心区别
Sinon 还提供了语义相近的 stub.callsThrough(),二者的文档描述几乎一致,唯一差异在于调用原始方法的运算符:
callsThrough():以普通函数调用方式执行原始方法(wrappedMethod.apply(context, args));callThroughWithNew():以new运算符方式实例化原始方法。
参考 calls-through.test.js 的用法,callsThrough() 适合包装普通函数:
const obj = {};
obj.sum = function sum(a, b) {
return a + b;
};
sinon
.stub(obj, "sum")
.withArgs(2, 2)
.callsFake(function foo() {
return "bar";
});
obj.sum.callThrough(); // 为未命中分支开启普通函数穿透
obj.sum(2, 2); // => "bar"(命中 withArgs 分支)
obj.sum(1, 2); // => 3(未命中,穿透调用原始 sum)
选型建议:
- 被包装的原方法是构造函数、且测试需要拿到"真实 new 出来的实例",用
callThroughWithNew(); - 被包装的原方法是普通函数,需要透传返回值,用
callsThrough()。
如果对构造函数误用 callsThrough(),会得到 this 未绑定或返回值不符合预期(普通函数调用不会创建新实例);反之对普通函数误用 callThroughWithNew(),行为同样不符合普通函数调用语义。二者不可混用。
四、与条件 stub(withArgs)的协作逻辑
callThroughWithNew() 的"兜底"性质决定了它与条件 stub 的分工关系。Sinon 的 stub 调用解析链路如下(参见 stub.js):
- 调用发生时,
functionStub()通过proxy.matchingFakes(args)收集所有实参匹配的条件 fake; - 在匹配项中按
matchingArguments.length排序并取出匹配参数最多的那个(stub.js L47-L54); - 若存在匹配分支,执行该分支的行为;若不存在,回退到 stub 的默认行为(stub.js L225-L230 的
getCurrentBehavior); - 默认行为是
callThroughWithNew时,进入new穿透分支。
对应到示例中:
new obj.Sum(1, 2):实参(1, 2)与withArgs(1, 2)深度匹配 → 执行returns({ result: 9000 }),原始构造函数不执行;new obj.Sum(2, 2):实参(2, 2)不匹配任何条件分支 → 走默认行为 → 用new调用MyConstructor(2, 2),得到result === 4。
这一点在核心测试 stub-test.js 的 .callThroughWithNew 用例组中有明确验证:命中分支时断言原始构造函数调用计数为 0(assert.equals(callCount, 0));未命中分支时断言原始构造函数的实参原样透传(callArgs[0] === "not foo",且第二参数 ["definitely", "not", "foo"] 也完整保留),返回值是 new 出来的实例(result.foo === "baz")。
值得注意的还有 withArgs 的匹配规则:默认采用深比较(deep comparison),若需严格引用比较应使用 stub.withArgs(sinon.match.same(obj)),这一点在 with-args.md 文档中有专门说明。
五、源码级原理:穿透是如何实现的
callThroughWithNew 的行为由两部分组成:状态标记(default-behaviors)与调用分发(behavior.invoke)。
5.1 行为注册:default-behaviors.js
在 default-behaviors.js 中,callThroughWithNew 行为被实现为:
callThroughWithNew: function callThroughWithNew(fake) {
fake.callsThroughWithNew = true;
fake.callArgAt = undefined;
fake.exception = undefined;
fake.exceptionCreator = undefined;
fake.throwArgAt = undefined;
fake.callArgProp = undefined;
fake.callbackArguments = [];
fake.callbackContext = undefined;
fake.callbackAsync = false;
},
它做两件事:
- 置位
fake.callsThroughWithNew = true,作为"穿透 + new"的开关标志; - 清理其他行为状态(异常、回调参数、抛参位置等),确保默认行为是纯净的"穿透构造"语义。
对比同文件中的 callThrough(对应 callsThrough()),它清理的状态更多(还清掉了 returnValue、fakeFn、resolve/reject 等),说明二者都遵循"设置穿透行为 = 重置其他默认行为"的互斥设计。
5.2 调用分发:behavior.js 的 invoke 分支
真正执行穿透的逻辑在 behavior.js 的 invoke 方法中(L192-L202):
} else if (this.callsThroughWithNew) {
// Get the original method (assumed to be a constructor in this case)
const WrappedClass = this.effectiveWrappedMethod();
// Turn the arguments object into a normal array
const argsArray = slice(args);
// Call the constructor
const F = WrappedClass.bind.apply(
WrappedClass,
concat([null], argsArray),
);
return new F();
}
这段实现包含三个值得深入解读的细节:
effectiveWrappedMethod()(behavior.js L212-L218):沿stub.parent链向上回溯,找到第一个带wrappedMethod的 stub 并返回其原始方法;找不到则抛出"Unable to find wrapped method"。这保证了即使 stub 经过withArgs派生(子 fake 的parent指向根 stub),依然能定位到真正的原始构造函数。argsArray = slice(args):arguments对象是类数组,需要先转成真正的数组才能参与bind.apply参数展开。WrappedClass.bind.apply(WrappedClass, concat([null], argsArray))+new F():这是"用动态参数列表调用构造函数"的经典技巧。Function.prototype.bind的第一个参数是this绑定值(这里传null,因为构造函数内部不依赖外部this),后续参数会前置到新函数的形参列表;apply负责把argsArray逐个展开。最终得到F等价于"已绑定好实参的构造函数",再new F()即可创建实例。
整个 invoke 方法的分发顺序(异常 → 返回参数 → 返回 this → 抛出参数 → fakeFn → resolve 系列 → callsThrough → callsThroughWithNew → 返回固定值)也解释了为什么 callThroughWithNew 是"默认兜底行为"而不是优先级最高的行为——只要其他行为(如 returns、throws、fakeFn)被显式设置,它们就会优先于穿透执行。
六、适用场景与注意事项
推荐场景:
- 需要验证"构造函数默认逻辑正确、特定参数被 mock 掉"的混合测试,例如工厂函数、配置类、依赖注入容器中的构造逻辑;
- 在单元测试中保留真实实例化行为,同时为特定入参注入替身结果;
- 与 sandbox 搭配使用,用
sandbox.stub()自动在用例结束后恢复原始方法,避免restore()遗漏。
注意事项:
- 仅对包装型 stub 有效:
callThroughWithNew()依赖wrappedMethod的存在,因此必须通过sinon.stub(object, "method")创建,匿名 stub(sinon.stub())无法穿透到任何原始实现,会抛出Unable to find wrapped method; - 原始方法必须是构造函数语义:源码注释明确写有 "assumed to be a constructor in this case",对非构造函数使用将产生不符合预期的结果;
- 与
onCall()的差异:callThroughWithNew()是"默认行为级"的穿透,而onCall(n)是按调用次数设置的分支行为(参见 stub.js 的onCall实现与 on-call.md),两者可组合使用:例如用onCall(0).returns(...)处理第一次调用,其余调用走callThroughWithNew()的构造穿透; - 测试后还原:包装型 stub 会替换对象属性,测试结束后应调用
obj.Method.restore()或sinon.restore()恢复原状(示例中即如此),否则会影响后续用例; - 新代码优先考虑 Fakes:按 Stubs 概览文档 的建议,
sinon.fake是大多数场景的推荐替代,但callThroughWithNew()这类"按参数分支 + 构造穿透"的高级场景仍属于 stub 的典型适用面。
七、总结
stub.callThroughWithNew() 以一行链式调用,为测试者提供了"条件 stub 优先、原始构造函数兜底"的精确控制能力。其实现(behavior.js 的 invoke + default-behaviors.js 的状态标记)展示了 Sinon 行为系统的分层设计:withArgs 分支按实参深度匹配优先命中,未命中时默认行为通过 bind.apply + new 完成对原始构造函数的实例化透传。配合 call-through-with-new.test.js 与 stub-test.js 中的测试用例,读者可以在此基础上举一反三,把该方法组合进 onCall、onFirstCall 或 sandbox 场景中,构建更灵活的构造函数测试策略。