4. 测试替身与依赖隔离

单元测试要快、要稳定、要能独立定位问题,就必须把被测对象从数据库、HTTP 服务、文件系统、时钟、消息队列等外部依赖中“摘”出来。测试替身(Test Double)就是用来替代真实协作者的对象。它不是一个单一概念,而是一组职责不同的角色:Stub、Mock、Fake、Spy,以及更底层的 Dummy。理解它们的差别,直接决定了测试是在验证“行为”还是验证“状态”,也决定了测试是否容易维护。

四类替身的职责边界

Stub(桩)只负责提供预设的返回值。它回答“当被调用时返回什么”,不关心被调用了几次。例如订单服务依赖 IExchangeRateProvider,测试中让桩在传入 "USD" 时返回 7.20,用于驱动后续金额计算。

Mock(模拟对象)关注交互行为:它预先声明“期望被以什么参数调用几次”,并在测试结束时验证这些期望是否满足。Mock 适合验证“被测对象是否正确地与协作者通信”,例如确认下单成功后确实调用了一次 IEmailSender.Send。

Fake(伪实现)是一个可工作的简化实现,通常带有内存数据结构。最典型的是内存仓储:InMemoryOrderRepository 用 Dictionary<Guid, Order> 实现增删查改,行为接近真实仓储但没有数据库。Fake 让测试覆盖更完整的读写路径,代价是需要自己维护其正确性。

Spy(间谍)在提供真实或桩式返回值的同时,记录调用历史,测试结束后再断言“是否调用过、参数是什么”。它介于 Stub 与 Mock 之间:不预先设定期望,而是事后检查。

选择原则可以概括为:验证输出状态用 Stub/Fake,验证交互行为用 Mock/Spy。能用状态断言就不要用交互断言,因为状态断言对实现细节的耦合更小。

测试替身四类角色:输入、行为与断言方式 被测对象 OrderService 依赖抽象接口 Stub 桩 提供预设返回值 输入 "USD" → 7.20 断言:输出状态 Mock 模拟对象 预设调用期望 Send 恰好调用 1 次 断言:交互行为 Fake 伪实现 内存 Dictionary 仓储 可读写、行为接近真实 断言:输出状态 Spy 间谍 记录调用历史 事后检查参数与次数 断言:交互行为 选择依据 • 状态可验证 → Stub/Fake • 行为必须验证 → Mock • 事后检查 → Spy • 仅占位 → Dummy 优先状态断言,减少耦合

Moq 与 NSubstitute 的使用

Moq 与 NSubstitute 都是 .NET 生态中成熟的动态代理框架,二者能力接近,语法风格不同。Moq 使用 Setup/Verify 显式区分“配置”与“验证”;NSubstitute 用 Returns 配置、用 Received 验证,读起来更接近自然语言。

// Moq:Stub 行为 + Mock 验证
var rateStub = new Mock<IExchangeRateProvider>();
rateStub.Setup(p => p.GetRate("USD")).Returns(7.20m);

var mailMock = new Mock<IEmailSender>();
var service = new OrderService(rateStub.Object, mailMock.Object);

service.PlaceOrder(new Order { Amount = 100m, Currency = "USD" });

mailMock.Verify(m => m.Send(It.IsAny<string>(), "订单已创建"), Times.Once);

// NSubstitute:等价写法
var rate = Substitute.For<IExchangeRateProvider>();
rate.GetRate("USD").Returns(7.20m);
var mail = Substitute.For<IEmailSender>();

var svc = new OrderService(rate, mail);
svc.PlaceOrder(new Order { Amount = 100m, Currency = "USD" });

mail.Received(1).Send(Arg.Any<string>(), "订单已创建");

两者都要求被替代的类型是接口、抽象类或虚成员,因为动态代理需要可重写的成员。对密封类或非虚方法,Moq 与 NSubstitute 都无法直接代理,此时应改为面向接口设计,或使用 Fake 手写实现。

对接口与抽象依赖进行隔离

依赖隔离的前提是依赖倒置:高层模块依赖抽象,而不是具体实现。若 OrderService 直接 new SqlOrderRepository(),测试就无法替换它。正确做法是在构造函数注入 IOrderRepository、IExchangeRateProvider、IEmailSender 等抽象,测试时传入替身,生产环境由 DI 容器注入真实实现。

隔离的粒度也要控制。一个类若依赖 6 个接口,测试就要准备 6 个替身,这通常说明该类职责过多。此时应优先考虑拆分职责,而不是继续堆替身。

避免过度 mock 与可维护性权衡

过度 mock 的典型症状是:测试里大量 Setup 与 Verify,一旦重构内部实现,测试就大面积失败,但业务行为其实没变。这类测试验证的是“实现细节”,而不是“可观察行为”,维护成本极高。

几条实用准则:

  • 只 mock 直接协作者,不要 mock 协作者的协作者,否则测试会穿透多层实现。
  • 优先状态断言:能检查返回值或仓储状态,就不要用 Verify 检查调用次数。
  • 不 mock 值对象与纯函数,它们没有副作用,直接使用真实对象即可。
  • 不 mock 自己拥有的类型,只 mock 外部边界(第三方服务、数据库、时钟、文件系统)。
  • Fake 优于复杂 Mock 链:当交互复杂时,一个内存 Fake 往往比十几个 Setup 更稳定。
判断标准:如果重构被测类的内部实现(不改变外部行为)会导致测试失败,那么这个测试很可能过度耦合了实现细节,应改用状态断言或 Fake。

一个可复用的替身选择检查表

场景推荐替身断言方式
需要固定返回值驱动计算Stub检查计算结果
必须确认通知/日志被触发MockVerify 调用次数与参数
需要完整读写路径Fake检查仓储内状态
事后检查调用参数Spy读取调用记录
仅填充参数、不被使用Dummy无

把替身选择与断言方式绑定起来,测试的可读性和可维护性会明显提升:读到一个 Verify 就知道在验证交互,读到一个 Fake 就知道在验证状态。测试替身不是越多越好,而是越贴合“要验证什么”越好。