在 .NET 的单元测试世界中,Moq 和 NSubstitute 是两个最受欢迎的模拟(Mocking)框架。它们都旨在帮助开发者创建测试替身(Test Doubles),以便在隔离的环境中测试代码单元。尽管目标相同,但它们在设计理念、API 语法和使用体验上存在显著差异。
这是两者最根本的区别,影响了它们的一切。
-
Moq: "录制-回放/期望" 模式 (Record-Replay/Expectation)
- 理念: Moq 的 API 围绕着“设置期望 (
Setup)”和“验证 (Verify)”这两个核心概念构建。你首先要明确地告诉 mock 对象:“当你接收到某个特定调用时,你应该如何反应”。然后,在测试的最后,你可以验证这个调用是否真的发生了。 - 感觉: 像是在编写一个脚本。你先为 mock 对象定义好所有规则,然后执行测试逻辑,最后检查 mock 是否按脚本演出了。API 结构非常严谨和明确。
- 理念: Moq 的 API 围绕着“设置期望 (
-
NSubstitute: "替代品/状态" 模式 (Substitute/State-Based)
- 理念: NSubstitute 旨在创建一个行为更像真实对象的“替代品”。你不是在设置严格的期望,而是在配置这个替代品的状态和行为。它更关注“当……时,返回……”,而不是强制性的“我期望……被调用”。验证调用也是事后检查,感觉更自然。
- 感觉: 像是在与一个真实的、可配置的对象交互。它的语法更接近自然语言,减少了“仪式感”,让开发者更专注于测试逻辑本身。
我们将通过常见的测试场景来对比两者的语法。假设我们有以下接口:
public interface ICalculator
{
int Add(int a, int b);
string Mode { get; set; }
void PowerOn();
event EventHandler PoweringUp;
}场景一:创建 Mock/Substitute
-
Moq:
var mockCalculator = new Mock<ICalculator>(); ICalculator calculator = mockCalculator.Object; // 必须通过 .Object 属性获取实例
-
NSubstitute:
ICalculator calculator = Substitute.For<ICalculator>(); // 直接返回实例
对比: NSubstitute 更简洁,一步到位。Moq 则将 mock 的配置对象 (
Mock<T>) 和实际使用的对象 (.Object) 分开,这在某些高级场景下更灵活,但日常使用稍显繁琐。
场景二:设置返回值 (Stubbing)
-
Moq: 使用
Setup和ReturnsLambda 表达式。mockCalculator.Setup(c => c.Add(1, 2)).Returns(3); // 测试代码 var result = calculator.Add(1, 2); // result会是3
-
NSubstitute: 直接在方法调用后使用
.Returns()。calculator.Add(1, 2).Returns(3); // 测试代码 var result = calculator.Add(1, 2); // result会是3
对比: NSubstitute 的流式 API(Fluent API)在这里的可读性通常被认为更高,更接近自然语言。
场景三:参数匹配
当你不关心传入的具体参数时。
-
Moq: 使用
It类。// 匹配任意整数 mockCalculator.Setup(c => c.Add(It.IsAny<int>(), It.IsAny<int>())).Returns(100); // 匹配满足条件的参数 mockCalculator.Setup(c => c.Add(It.Is<int>(i => i > 0), 5)).Returns(10);
-
NSubstitute: 使用
Arg类。// 匹配任意整数 calculator.Add(Arg.Any<int>(), Arg.Any<int>()).Returns(100); // 匹配满足条件的参数 calculator.Add(Arg.Is<int>(i => i > 0), 5).Returns(10);
对比: 语法非常相似,只是静态类的名字不同 (
ItvsArg)。两者功能上基本等价。
场景四:验证方法调用 (Verification)
-
Moq: 使用
Verify方法,非常强大和精确。// 验证 Add(1, 2) 被调用了一次 mockCalculator.Verify(c => c.Add(1, 2), Times.Once()); // 验证 PowerOn 被调用了至少两次 mockCalculator.Verify(c => c.PowerOn(), Times.AtLeast(2)); // 验证 Add 方法从未被任何负数调用 mockCalculator.Verify(c => c.Add(It.Is<int>(i => i < 0), It.IsAny<int>()), Times.Never());
-
NSubstitute: 使用
Received()和DidNotReceive()。// 验证 Add(1, 2) 被调用了 (默认至少一次) calculator.Received().Add(1, 2); // 验证 PowerOn 被调用了两次 calculator.Received(2).PowerOn(); // 验证 Add 方法从未被任何负数调用 calculator.DidNotReceive().Add(Arg.Is<int>(i => i < 0), Arg.Any<int>());
对比: NSubstitute 的语法再次显得更流畅自然。Moq 的
Times枚举功能非常强大,可以进行非常复杂的调用次数验证,而 NSubstitute 通过Received(N)也能满足绝大多数需求。
场景五:属性操作
-
Moq:
// 设置属性返回值 mockCalculator.Setup(c => c.Mode).Returns(\"Decimal\"); // 验证属性被设置 mockCalculator.SetupSet(c => c.Mode = \"Hex\").Verifiable(); // ... mockCalculator.Verify(); // 自动追踪属性值变化 mockCalculator.SetupAllProperties(); calculator.Mode = \"Binary\"; Assert.AreEqual(\"Binary\", calculator.Mode);
-
NSubstitute:
// 设置属性返回值 calculator.Mode.Returns(\"Decimal\"); // NSubstitute 默认就会像真实对象一样追踪属性值,无需额外设置 calculator.Mode = \"Binary\"; Assert.AreEqual(\"Binary\", calculator.Mode); // 验证属性被设置 calculator.Received().Mode = \"Binary\";
对比: NSubstitute 在处理属性时极其简单直观,其行为更符合人们对一个普通对象的预期。Moq 需要更多的显式配置。
| 特性 | Moq | NSubstitute | 备注 |
|---|---|---|---|
| API 风格 | 基于 Lambda 表达式的 Setup 和 Verify |
流式(Fluent)、接近自然语言 | NSubstitute 通常被认为学习曲线更平缓,代码可读性更高。 |
| 严格/松散模式 | 支持严格模式 (MockBehavior.Strict),任何未经 Setup 的调用都会抛出异常。 |
默认松散。所有未配置的方法/属性默认返回 null 或 default(T)。没有内置的严格模式。 |
Moq 的严格模式有助于发现意外的依赖调用,但也会让测试变得更脆弱。 |
| 非虚方法/静态方法 | 不支持(需要商业版的 Moq.AutoMocker 或其他工具如 Pose, Harmony) |
不支持 | 这是所有基于代理的 mocking 框架的共同限制。 |
| 学习曲线 | 稍陡峭,需要理解 Setup/Verify 的概念。 |
非常平缓,API 直观易懂。 | 新手通常能更快地掌握 NSubstitute。 |
| 社区与维护 | 历史上是 .NET 社区的绝对王者,用户基数巨大。 | 社区活跃,非常受欢迎,近年来使用者增长迅速。 | 重要: 见下文的“SponsorLink 争议”。 |
| 灵活性与控制 | 提供了非常精细的控制,如 SetupSequence, Verifiable, Callback 等。 |
API 设计更简洁,虽然也支持回调等高级功能,但总体上更注重易用性。 | Moq 在处理极其复杂的模拟场景时可能提供更多底层控制。 |
在 2023 年 8 月,Moq 4.20.0 版本引入了一个名为 SponsorLink 的依赖。这个依赖会在编译时收集开发者邮箱的 SHA-256 哈希值并上传到其服务器,以验证用户是否是 GitHub Sponsor。这一行为在未经用户明确同意的情况下收集数据,引发了社区的强烈反弹和信任危机。
- 影响:
- 信任破裂: 许多开发者和公司因隐私和安全顾虑而放弃或禁止使用 Moq。
- 版本锁定: 很多项目选择锁定在 4.20.0 之前的版本(如 4.18.4)。
- 替代品崛起: 这次事件极大地推动了 NSubstitute 和 FakeItEasy 等替代品的采用率。
尽管 Moq 的作者后来移除了这个具有争议的功能,但其声誉已经受到了严重损害。
-
如果你是新手或开始一个新项目: 强烈推荐 NSubstitute。它的学习曲线平缓,API 优雅且可读性高,能让你更专注于编写测试逻辑而不是模拟框架的语法。此外,它没有历史包袱和信任问题,社区健康活跃。
-
如果你正在维护一个大量使用 Moq 的旧项目:
- 迁移成本高: 全部替换成 NSubstitute 可能是一项巨大的工程。
- 建议:
- 将项目中的 Moq 版本锁定在 4.18.4 或更早,以避免 SponsorLink 争议。
- 对于项目中新增的测试,可以考虑开始使用 NSubstitute,逐步过渡。
- 评估迁移的可行性。如果项目会长期维护,长痛不如短痛,制定计划逐步迁移是值得的。
-
为什么还会有人选择 Moq?:
- 习惯与存量: 它是事实上的“前任标准”,无数的教程、博客和现有代码库都在使用它。
- 强大的控制力: 对于某些需要极其精细控制的复杂测试场景,Moq 的
SetupAPI 仍然非常强大。 - 严格模式: 如果你的团队非常依赖严格模式来规范测试,Moq 是原生支持的。
总而言之,在当前(2023年后)的 .NET 生态中,NSubstitute 因其简洁的设计、优秀的开发者体验以及可靠的社区声誉,已成为大多数项目(尤其是新项目)的首选。Moq 依然是一个功能强大的框架,但其近期的争议使其推荐度大大降低。