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。能用状态断言就不要用交互断言,因为状态断言对实现细节的耦合更小。
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 更稳定。
一个可复用的替身选择检查表
| 场景 | 推荐替身 | 断言方式 |
|---|---|---|
| 需要固定返回值驱动计算 | Stub | 检查计算结果 |
| 必须确认通知/日志被触发 | Mock | Verify 调用次数与参数 |
| 需要完整读写路径 | Fake | 检查仓储内状态 |
| 事后检查调用参数 | Spy | 读取调用记录 |
| 仅填充参数、不被使用 | Dummy | 无 |
把替身选择与断言方式绑定起来,测试的可读性和可维护性会明显提升:读到一个 Verify 就知道在验证交互,读到一个 Fake 就知道在验证状态。测试替身不是越多越好,而是越贴合“要验证什么”越好。