Sinon `stub.callThroughWithNew()`:让 Stub 以 `new` 运算符调用原始构造函数

原创2026-09-24 23:37:081,434 阅读
文章标签:测试开发工具

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 new operator, when none of the conditional stubs are matched.

翻译过来包含三个关键限定:

  1. 对象是"被包装的原始方法"(wrapped method)——它只对 sinon.stub(object, "method") 这类"包装既有函数"的 stub 有意义,对匿名 stub 无效;
  2. 调用方式是 new——即把原始方法当作构造函数来实例化,而不是普通函数调用;
  3. 触发时机是"条件 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):

  1. 调用发生时,functionStub() 通过 proxy.matchingFakes(args) 收集所有实参匹配的条件 fake;
  2. 在匹配项中按 matchingArguments.length 排序并取出匹配参数最多的那个(stub.js L47-L54);
  3. 若存在匹配分支,执行该分支的行为;若不存在,回退到 stub 的默认行为(stub.js L225-L230 的 getCurrentBehavior);
  4. 默认行为是 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;
},

它做两件事:

  1. 置位 fake.callsThroughWithNew = true,作为"穿透 + new"的开关标志;
  2. 清理其他行为状态(异常、回调参数、抛参位置等),确保默认行为是纯净的"穿透构造"语义。

对比同文件中的 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();
}

这段实现包含三个值得深入解读的细节:

  1. effectiveWrappedMethod()(behavior.js L212-L218):沿 stub.parent 链向上回溯,找到第一个带 wrappedMethod 的 stub 并返回其原始方法;找不到则抛出 "Unable to find wrapped method"。这保证了即使 stub 经过 withArgs 派生(子 fake 的 parent 指向根 stub),依然能定位到真正的原始构造函数。
  2. argsArray = slice(args):arguments 对象是类数组,需要先转成真正的数组才能参与 bind.apply 参数展开。
  3. 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() 遗漏。

注意事项:

  1. 仅对包装型 stub 有效:callThroughWithNew() 依赖 wrappedMethod 的存在,因此必须通过 sinon.stub(object, "method") 创建,匿名 stub(sinon.stub())无法穿透到任何原始实现,会抛出 Unable to find wrapped method;
  2. 原始方法必须是构造函数语义:源码注释明确写有 "assumed to be a constructor in this case",对非构造函数使用将产生不符合预期的结果;
  3. 与 onCall() 的差异:callThroughWithNew() 是"默认行为级"的穿透,而 onCall(n) 是按调用次数设置的分支行为(参见 stub.js 的 onCall 实现与 on-call.md),两者可组合使用:例如用 onCall(0).returns(...) 处理第一次调用,其余调用走 callThroughWithNew() 的构造穿透;
  4. 测试后还原:包装型 stub 会替换对象属性,测试结束后应调用 obj.Method.restore() 或 sinon.restore() 恢复原状(示例中即如此),否则会影响后续用例;
  5. 新代码优先考虑 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 场景中,构建更灵活的构造函数测试策略。

登录后查看全文
sinon