让我们踏上探索.NET软件开发中Mocking概念的旅程,让我们深入了解Mocking是多么简单易懂、易于访问。请与我一起穿越这个主题,我将涵盖以下内容:
- 理解Mocking:为何它对于构建强大的测试策略至关重要。
- 探索一些最常见的Mocking库:如Moq、NSubstitute、FakeItEasy和Rhino Mocks等。
- 掌握Mocking技术:使用每个库,为您提供选择最佳Mocking工具的知识,以满足您的需求。
本教程的目的是让您具备足够的知识,以便自行决定您偏好的Mocking库,这样您就可以继续在应用程序中编写一些强大的测试。
(本文视频讲解:java567.com)
本教程的先决条件
- 理解C#编程
- 理解C#单元测试
- 一个IDE(Rider、Visual Studio等)或代码编辑器(VS Code、Sublime Text、Atom等)
入门/设置
为了加快进程,我已经为本教程提供了一个公共GitHub仓库。您可以将其克隆到本地计算机上以便跟随操作。
只需转到此链接并将仓库克隆到本地计算机即可。
如果您忘记了如何操作,请快速复习一下:转到上面的链接,在右上角点击"Code",然后复制提供的URL。
找到您的本地git 文件夹(如果没有,请在您的用户根文件夹中创建一个)。然后在您喜欢的终端中导航到您的git文件夹,并执行以下命令。
terminal
//(用URL替换<url>)
git clone <url>
应用程序简要概述
您刚刚克隆的解决方案是一个基本的Web API项目,它引用了一个包含一些Todo领域对象和服务的类库,这些对象和服务将改变一个todo项目列表。
为了本教程的目的,这些元素存储在内存列表中,而不是连接到数据库。但您可以使用数据库或其他形式的数据持久化方法。
然而,对于本教程的目的,我们不太关心数据的持久化,而更关心的是对这个服务进行Mocking。
Mocking是什么?
通过Mocking,你无法区分真实与虚假
Mocking在软件开发中是模拟系统测试中某个部分依赖的类、方法或服务的行为或返回对象的概念。
在测试特定组件或代码单元时,通常需要将其与其依赖项(如数据库、Web服务或其他类)隔离开来,以确保测试仅专注于被测试单元的功能,而不必关注代码的外部方面。
Mocking允许开发人员创建这些依赖项的模拟对象或虚拟实现,以预定的方式行为,模仿真实对象的行为。
通过使用模拟对象或方法,我们可以控制依赖项的输入和输出。这使得测试不同的情景变得更加容易,其中业务逻辑依赖于依赖项的结果。
示例
假设我们有一个API端点调用一个连接到数据库的存储库。我们的API响应类型取决于存储库返回的值:我们从API返回一个400或200的响应。
我们可以简单地模拟存储库返回值,并测试我们的API在这两种情况下是否返回了正确的响应,而无需实际触及数据库。
实质上,Mocking通过隔离待测试的代码并为其依赖项提供可预测的行为,帮助开发人员编写更可靠、高效的测试,从而提高软件的整体质量。
Mocking的好处是什么?
好处
代码隔离
正如我已经解释过的那样,Mocking依赖项允许对代码进行测试隔离。通过Mocking依赖项,您可以断言被测试代码中的失败点,而不是依赖项本身(除非您Mocking它们错误------希望本教程能帮助您避免这种情况)。
更快的测试速度
通过用Mocking实现替换真实依赖项,可以更快地进行测试。您不必与这些依赖项的不一致性、不可靠或不可预测的结果作斗争。这消除了设置和拆卸外部资源的需要,这有时可能会很复杂且耗时。
确定性测试
Mock对象提供了一个可控制的环境,使开发人员能够指定依赖项的确切行为和响应。这种方法意味着测试是一致的,从而更容易找到错误并调试问题,特别是采用TDD(测试驱动开发)方法的情况下。
并行测试
Mocking通过消除对可能在测试期间受限或无法访问的外部资源的依赖,实现了并行测试(同时运行多个测试)。
例如,如果您没有Mocking您的数据库连接层,尝试并行运行测试可能会导致不一致的通过/失败度量,因为另一个测试可能会影响您在另一个测试中使用的数据库表。Mocking这一层意味着您的测试现在与此层无关,可以同时运行。
减少测试维护工作
由于Mock对象封装了依赖项的行为,因此对这些依赖项的更改不一定需要更新测试本身。这减少了与不断发展的代码库和依赖项相关的维护开销。
增强的测试覆盖范围
Mocking允许开发人员模拟各种场景和边界情况。通过控制依赖项的行为,开发人员可以确保他们的测试覆盖了代码的所有相关路径,消除了任何现实生活或物理限制。
使用Mocking需要注意的事项
复杂性: 有时在对应用程序的复杂区域进行Mocking/测试时,Mocking也可能变得复杂。然而,如果系统过于难以Mocking,您可能需要重新评估您的架构。
学习曲线: 这需要理解Mocking库的语法和概念,这对于刚接触单元测试或特定框架的开发人员来说可能是具有挑战性的。
过度规范化: Mocking可能导致对被测试代码行为进行过度规范化。这意味着测试可能会与实现细节紧密耦合,使其变得脆弱,并在实现更改时容易中断。在验证行为和专注于期望结果之间保持平衡至关重要。
要注意错误的正测试: 虽然Mocking允许您隔离代码单元,但它也可能会产生一种虚假的安全感。Mock模拟依赖项,但它们可能无法完全复制真实依赖项的行为。仍然需要进行集成测试或端到端测试来验证系统的整体行为。
流行的.NET Mocking库
以下是一些流行的.NET测试库:
- FakeItEasy
- NSubstitute
- Moq
- Rhino Mocks
这只是在线可用的一些最常用的.NET Mocking库的列表,但还有许多其他库。我强烈建议使用其中之一,因为它们有着更大的社区、更值得信赖的代码库和良好的文档(在开始时至关重要)。
这些Mocking库都有自己创建对象的语法,但它们都遵循相同的原则。
- 声明要Mock的对象/类型/服务
- 您希望该对象/类型/服务如何运行(实现)
- 返回值是什么(在必要时)。
查看测试
如果您打开解决方案,并导航到"Test"项目,您会发现我们在那里有四个文件,每个文件中都包含不同的Mocking库测试。
- FakeItEasyApiTests.cs
- MoqApiTests.cs
- NSubstituteApiTests.cs
- RhinoMocksApiTests.cs
在这些文件中,您将看到我们有四个非常基本的XUnit测试。出于本教程的目的,我将它们保持简短和简单。
深入了解测试结构
每个测试文件都使用构造函数来初始化其各自服务的私有版本,您可以看到这些服务在不同库之间的差异,但仍遵循相同的概念。
csharp
// FakeItEasy
_fakeTodoService = A.Fake<ITodoService>();
// NSubstitute
_substituteTodoService = Substitute.For<ITodoService>();
// Moq
_mockTodoService = new Mock<ITodoService>();
// Rhino Mocks
_mockTodoService = MockRepository.GenerateMock<ITodoService>();
选择"正确"的Mocking库完全取决于个人偏好,以及您认为哪种更容易编写、使用和阅读/理解。
有些人可能会发现使用类似于Fake
或Substitute.For
这样的词更容易理解或阅读。而其他人可能会觉得A.Fake
不直观,更喜欢new Mock<type>
更明显。
排列、执行和断言
遵循AAA(排列、执行和断言)测试原则,我们可以仔细构建我们的测试,使得我们正在做什么和在哪里做什么变得明显。
AAA测试方法包括三个步骤:
- 排列:设置测试环境,模拟的服务/外部依赖项。
- 执行:执行正在测试的操作。
- 断言:验证期望的结果。
使用模拟对象模拟返回项
让我们通过模拟TodoService.GetAllTodos
方法返回一组存根任务来测试getAll
(GetAllTodoItems)端点。
这种方法消除了为每个测试场景设置和填充数据库的需要,确保针对特定标准测试返回值的API端点。
模拟提供了一个理想的解决方案,允许我们模拟所需的行为,而不受其他测试的干扰。
我们可以这样做(记住完整的代码在存储库中可用):
csharp
// FakeItEasy
A.CallTo(() => _fakeTodoService.GetAllTodos()).Returns(expectedTodos);
// NSubstitute
_substituteTodoService.GetAllTodos().Returns(expectedTodos);
// Moq
_moqTodoService.Setup(s => s.GetAllTodos()).Returns(expectedTodos);
// Rhino Mocks
_mockTodoService.Stub(s => s.GetAllTodos()).Return(expectedTodos);
这些方法是做什么的?
您将在大多数库中看到的一个常见特性是使用lambda函数来指示要模拟的方法。
在设置方法中提供的lambda函数实际上是一个配置步骤,定义了调用模拟方法时应采取的操作。此配置在测试期间调用模拟方法时存储和应用。
让我们分解一下,我们正在做什么:
- Lambda指定了我们希望在模拟服务上配置/设置的方法。
- 我们传递的lambda不会立即由设置方法运行。我们没有要求测试立即运行该方法;我们的意思是,"记住这个指令/设置,以备在测试期间调用实际方法时使用。"
- 当我们模拟的方法在测试期间被调用时,它会检查调用签名是否与我们提供的设置配置相匹配。 如果匹配,测试会遵循设置期间给出的指示。
重要说明:
另一方面,NSubstitute允许开发人员直接在虚拟对象上模拟方法。这意味着您可以访问虚拟的GetAllTodos
方法,并简单地将返回值设置为您期望的值。
虽然Moq使用了Setup方法,但与其他方法略有不同。Moq在内部创建一个代理对象,代表了被模拟的对象,并公开了.Object
属性来访问它。我们将在下一部分中看到这是如何工作的。
如何调用被测试系统(SUT)
csharp
// FakeItEasy
var sut = new TodoController(_fakeTodoService);
// NSubstitute
var sut = new TodoController(_substituteTodoService);
// Moq -- 和其他的略有不同
var sut = new TodoController(_moqTodoService.Object);
// Rhino Mocks
var sut = new TodoController(_mockTodoService);
在四个库中的三个中,您可以直接传递模拟对象。然而,Moq需要在模拟上使用.Object
属性才能使用它。因此,我们将moqTodoService.Object
作为控制器的参数传递。
一张图片显示所有测试都通过了
运行测试,您可以看到它们都通过了。如果您更改存储库中的任何代码,这不会有任何影响,因为这些测试是模拟的。试一试,尝试更改存储库功能并重新运行测试,它们将继续通过。
我们专注于端点的功能,而不是模拟存储库正在执行的操作,这就是模拟的美妙之处。
Mocking的选择是无穷无尽的
模拟不仅允许我们设置模拟对象返回的内容,还可以允许我们模拟实现,包括抛出特定错误,以便测试我们的API错误处理和不正常路径。
这在每个库测试文件中的Delete_Returns500_AndErrorMessageThrown_WhenExceptionThrown
测试中有所体现。
csharp
// FakeItEasy
A.CallTo(() => _fakeTodoService.Delete(1)).Throws(new Exception(errorMessage));
// NSubstitute
_substituteTodoService.When(x => x.Delete(1)).Do(x => throw new Exception(errorMessage));
// Moq
_moqTodoService.Setup(s => s.Delete(1)).Throws(new Exception(errorMessage));
// Rhino Mocks
_mockTodoService.Stub(s => s.Delete(1)).Throw(new Exception(errorMessage));
使用不同的库,我们可以使服务上的Delete
方法抛出我们想要的任何异常。当您想要返回不同的状态代码或根据抛出的异常类型以不同的方式处理错误时,这是理想的。
例如,我们可以将Throws(new Exception(errorMessage)
更改为Throws(new UnauthorizedAccessException()
,并测试当抛出401状态代码时。
全局设置
您可以为同一方法分配多个配置。这在您想要在一个地方设置模拟对象的所有配置时非常有用。例如,在测试类构造函数中。
在某些测试框架(如NUnit)中,您可以在方法上方使用[OneTimeSetUp]
属性,该属性在测试用例之前运行,或者简单地使用测试类构造函数。
在这种情况下,您可以像在Moq中那样做一些事情:
csharp
public MoqApiTests()
{
_mockTodoService = new Mock<ITodoService>();
_mockTodoService.Setup(x => x.Delete(1)).Throws(new Exception("This is a generic exception"));
_mockTodoService.Setup(x => x.Delete(2)).Throws(new UnauthorizedAccessException("You cannot perform this action on this item"));
}
在这个例子中,我们演示了设置模拟服务调用相同方法的多个配置,每个配置导致抛出不同的异常。
这种方法有助于在不同的测试中测试不同异常发生时的不同结果,而不会使我们的测试代码因重复的设置而杂乱无章。
例如:
csharp
// 测试1
var result = TodoController.Delete(1);
// 断言处理一般异常
// 测试2
var result = TodoController.Delete(2);
// 断言处理UnauthorizedAccessException
我更喜欢在每个单独的测试中设置模拟,以确保没有外部因素影响模拟。
这样,我可以在测试中轻松识别被模拟的内容,而不必在其他地方搜索模拟对象和设置。
如果我不在乎我传递的是什么?
在我们的删除示例中,我们始终传递了一个ID给模拟实现。因此,如果我们通过TodoController.DeleteTodoItem
调用传递了一个不同的ID,比如101
,我们将不会收到相同的结果。
这是因为我们明确指示了模拟对象在调用存根方法时使用ID 1时抛出错误。
为了解决这个问题,我们可以更不具体。每个库都有自己的语法,使我们能够指定如果传递给方法的是任何整数,它将抛出特定的异常。
csharp
// FakeItEasy
A.CallTo(() => _fakeTodoService.Delete(A<int>._)).Throws( new Exception(errorMessage));
// NSubstitute
_substituteTodoService.When(x => x.Delete(Arg.Any<int>())).Do(x => throw new Exception(errorMessage));
// Moq
_mockTodoService.Setup(s => s.Delete(It.IsAny<int>())).Throws(new Exception(errorMessage));
// Rhino Mocks
_mockTodoService.Stub(s => s.Delete(Arg<int>.Is.Anything)).Throw(new Exception(errorMessage));
此代码表示当传递任何int
类型的参数时,它应该抛出此异常。
NSubstitute的语法略有不同:它需要在遇到这种情况时明确指示要抛出指定的错误,不像我们在通知它返回对象时那样。这种差异源于库的内部机制。
断言模拟对象被调用
在某些情况下,您可能希望验证模拟服务是否以正确的参数被调用。当处理"发出并忘记"服务时,这特别有用。
在这种情况下,您的API端点被调用,虽然它总是返回true,但它也会触发一个独立执行的服务执行某些操作,这不会影响API的返回类型(也许是一个异步通知服务)。
这是您可能希望执行一个快速的健全性检查以确保您的"发出并忘记"服务被调用的少数情况之一(尽管理想情况下,您会进行与该服务的集成测试)。
如果您查看DeleteTodoItem
端点以及每个测试文件中的DeleteAPI_CallsNotificationService_WithTaskId_AndUserId
测试,您可以看到如何完整地执行此操作的示例。
我们正在验证当我们调用DeleteTodoItem
时,在我们的正常路径上,NotificationService.NotifyUserTaskCompleted
被调用,传递了要删除的项目的ID和硬编码的用户ID。
作为练习,您可以创建一个用户服务,该服务返回已登录用户的ID,并将其用于传递ID给服务。这也可以在测试中进行模拟。
csharp
// FakeItEasy
A.CallTo(() => _fakeNotificationService.NotifyUserTaskCompleted(1,1)).MustHaveHappened(1, Times.Exactly);
// NSubstitute _notificationService.Received().NotifyUserTaskCompleted(1,1);
// Moq
_moqNotificationService.Verify(x => x.NotifyUserTaskCompleted(1,1)); // Defaults to Times.AtLeastOnce
// Rhino Mock
_mockNotificationService.AssertWasCalled(x=>x.NotifyUserTaskCompleted(1,1));
结论
总之,模拟对象的灵活性提供了许多应用场景,在测试单个代码单元时不可或缺。
尽管我已经涵盖了模拟的几个功能和可实现的验证,但还有更多,比如方法调用顺序和负验证。
在我看来,项目中选择模拟库是主观的,没有明确的正确或错误选项。虽然一些库可能提供更方便的扩展或更清晰的语法,但最终决定归根结底取决于个人偏好。
我希望这个教程为您提供了对模拟世界的一瞥,并阐明了不同库之间的语法差异。
(本文视频讲解:java567.com)