单元测试原则、实践与模式(九)
译者声明:本译文仅作为学习的记录,不作为商业用途,如有侵权请告知我删除。
本文禁止转载!
请支持原书正版:https://www.manning.com/books/unit-testing
This chapter covers
- Maximizing the value of mocks 最大限度地发挥mock的价值
- Replacing mocks with spies 用spies取代mocks
- Mocking best practices mock的最佳实践
As you might remember from chapter 5, a mock is a test double that helps to emulate and examine interactions between the system under test and its dependencies. As you might also remember from chapter 8, mocks should only be applied to unmanaged dependencies (interactions with such dependencies are observable by external applications). Using mocks for anything else results in brittle tests (tests that lack the metric of resistance to refactoring). When it comes to mocks, adhering to this one guideline will get you about two-thirds of the way to success.
你可能还记得第5章,mock是一个测试替身,它有助于模拟和检查被测系统和其依赖关系之间的交互。你可能还记得第8章,mock应该只应用于未被管理的依赖关系(与这种依赖关系的交互可被外部应用程序观察到)。在其他方面使用mock会导致脆性测试(测试缺乏抵抗重构的指标)。当涉及到mock时,遵守这一条准则将使你获得三分之二的成功。
This chapter shows the remaining guidelines that will help you develop integration tests that have the greatest possible value by maxing out mocks’ resistance to refactoring and protection against regressions. I’ll first show a typical use of mocks, describe its drawbacks, and then demonstrate how you can overcome those drawbacks.
本章展示了其余的准则,这些准则将帮助你开发集成测试,通过最大限度地提高mock对重构的抵抗力和对回归的保护,使其具有最大的价值。我将首先展示mocks的一个典型用途,描述它的缺点,然后演示如何克服这些缺点。
9.1 Maximizing mocks’ value
It’s important to limit the use of mocks to unmanaged dependencies, but that’s only the first step on the way to maximizing the value of mocks. This topic is best explained with an example, so I’ll continue using the CRM system from earlier chapters as a sample project. I’ll remind you of its functionality and show the integration test we ended up with. After that, you’ll see how that test can be improved with regard to mocking.
将mocks的使用限制在未被管理的依赖关系上是很重要的,但这只是实现mocks价值最大化的第一步。这个话题最好用一个例子来解释,所以我将继续使用前几章的CRM系统作为一个示例项目。我将提醒你它的功能,并展示我们最后的集成测试。之后,你会看到如何在mock方面改进该测试。
As you might recall, the CRM system currently supports only one use case: changing a user’s email. The following listing shows where we left off with the controller.
你可能还记得,CRM系统目前只支持一个用例:改变一个用户的电子邮件。下面的列表显示了我们在控制器上所做的工作。
Listing 9.1 User controller
public class UserController
{
private readonly Database _database;
private readonly EventDispatcher _eventDispatcher;
public UserController(
Database database,
IMessageBus messageBus,
IDomainLogger domainLogger)
{
_database = database;
_eventDispatcher = new EventDispatcher(
messageBus, domainLogger);
}
public string ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
string error = user.CanChangeEmail();
if (error != null)
return error;
object[] companyData = _database.GetCompany();
Company company = CompanyFactory.Create(companyData);
user.ChangeEmail(newEmail, company);
_database.SaveCompany(company);
_database.SaveUser(user);
_eventDispatcher.Dispatch(user.DomainEvents);
return "OK";
}
}
Note that there’s no longer any diagnostic logging, but support logging (the IDomainLogger interface) is still in place (see chapter 8 for more details). Also, listing 9.1 introduces a new class: the EventDispatcher. It converts domain events generated by the domain model into calls to unmanaged dependencies (something that the controller previously did by itself), as shown next.
注意,不再有任何诊断日志,但支持日志(IDomainLogger接口)仍然存在(更多细节见第8章)。另外,清单9.1介绍了一个新的类:EventDispatcher。它将领域模型产生的领域事件转换为对非托管依赖的调用(这是控制器之前自己做的),如下所示。
Listing 9.2 Event dispatcher
public class EventDispatcher
{
private readonly IMessageBus _messageBus;
private readonly IDomainLogger _domainLogger;
public EventDispatcher(
IMessageBus messageBus,
IDomainLogger domainLogger)
{
_domainLogger = domainLogger;
_messageBus = messageBus;
}
public void Dispatch(List<IDomainEvent> events)
{
foreach (IDomainEvent ev in events)
{
Dispatch(ev);
}
}
private void Dispatch(IDomainEvent ev)
{
switch (ev)
{
case EmailChangedEvent emailChangedEvent:
_messageBus.SendEmailChangedMessage(
emailChangedEvent.UserId,
emailChangedEvent.NewEmail);
break;
case UserTypeChangedEvent userTypeChangedEvent:
_domainLogger.UserTypeHasChanged(
userTypeChangedEvent.UserId,
userTypeChangedEvent.OldType,
userTypeChangedEvent.NewType);
break;
}
}
}
Finally, the following listing shows the integration test. This test goes through all outof-process dependencies (both managed and unmanaged).
最后,下面的列表显示了集成测试。这个测试通过所有进程外的依赖关系(包括管理的和非管理的)。
Listing 9.3 Integration test
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
// Arrange
var db = new Database(ConnectionString);
User user = CreateUser("user@mycorp.com", UserType.Employee, db);
CreateCompany("mycorp.com", 1, db);
var messageBusMock = new Mock<IMessageBus>();
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(
db, messageBusMock.Object, loggerMock.Object);
// Act
string result = sut.ChangeEmail(user.UserId, "new@gmail.com");
// Assert
Assert.Equal("OK", result);
object[] userData = db.GetUserById(user.UserId);
User userFromDb = UserFactory.Create(userData);
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);
object[] companyData = db.GetCompany();
Company companyFromDb = CompanyFactory.Create(companyData);
Assert.Equal(0, companyFromDb.NumberOfEmployees);
messageBusMock.Verify(
x => x.SendEmailChangedMessage(
user.UserId, "new@gmail.com"),
Times.Once);
loggerMock.Verify(
x => x.UserTypeHasChanged(
user.UserId,
UserType.Employee,
UserType.Customer),
Times.Once);
}
This test mocks out two unmanaged dependencies: IMessageBus and IDomainLogger. I’ll focus on IMessageBus first. We’ll discuss IDomainLogger later in this chapter.
这个测试模拟了两个未被管理的依赖项: IMessageBus和IDomainLogger。我将首先关注IMessageBus。我们将在本章后面讨论IDomainLogger。
9.1.1 Verifying interactions at the system edges
Let’s discuss why the mocks used by the integration test in listing 9.3 aren’t ideal in terms of their protection against regressions and resistance to refactoring and how we can fix that.
让我们讨论一下为什么清单9.3中的集成测试所使用的mock在防止回归和抵抗重构方面并不理想,以及我们如何解决这个问题。
TIP
When mocking, always adhere to the following guideline: verify interactions with unmanaged dependencies at the very edges of your system.
mock时,要始终坚持以下准则:在系统的最边缘验证与未管理的依赖关系的交互。
The problem with messageBusMock in listing 9.3 is that the IMessageBus interface doesn’t reside at the system’s edge. Look at that interface’s implementation.
列表9.3中的messageBusMock的问题是,IMessageBus接口并不在系统的边缘。看看这个接口的实现。
Listing 9.4 Message bus
public interface IMessageBus
{
void SendEmailChangedMessage(int userId, string newEmail);
}
public class MessageBus : IMessageBus
{
private readonly IBus _bus;
public void SendEmailChangedMessage(
int userId, string newEmail)
{
_bus.Send("Type: USER EMAIL CHANGED; " +
$"Id: {userId}; " +
$"NewEmail: {newEmail}");
}
}
public interface IBus
{
void Send(string message);
}
Both the IMessageBus and IBus interfaces (and the classes implementing them) belong to our project’s code base. IBus is a wrapper on top of the message bus SDK library (provided by the company that develops that message bus). This wrapper encapsulates nonessential technical details, such as connection credentials, and exposes a nice, clean interface for sending arbitrary text messages to the bus. IMessageBus is a wrapper on top of IBus; it defines messages specific to your domain. IMessageBus helps you keep all such messages in one place and reuse them across the application.
IMessageBus和IBus接口(以及实现它们的类)都属于我们项目的代码库。IBus是消息总线SDK库(由开发该消息总线的公司提供)之上的一个封装器。这个包装器封装了非必要的技术细节,如连接凭证,并为向总线发送任意文本消息提供了一个漂亮、干净的接口。IMessageBus是IBus上面的一个包装器;它定义了特定于你领域的消息。IMessageBus帮助你把所有这些消息放在一个地方,并在整个应用中重用它们。
It’s possible to merge the IBus and IMessageBus interfaces together, but that would be a suboptimal solution. These two responsibilities—hiding the external library’s complexity and holding all application messages in one place—are best kept separated. This is the same situation as with ILogger and IDomainLogger, which you saw in chapter 8. IDomainLogger implements specific logging functionality required by the business, and it does that by using the generic ILogger behind the scenes.
有可能将IBus和IMessageBus接口合并在一起,但这将是一个次优的解决方案。这两个职责—隐藏外部库的复杂性和将所有应用程序的消息保存在一个地方—最好分开。这与你在第8章看到的ILogger和IDomainLogger的情况相同。IDomainLogger实现了业务所需的特定日志功能,它通过在幕后使用通用的ILogger来实现。
Figure 9.1 shows where IBus and IMessageBus stand from a hexagonal architecture perspective: IBus is the last link in the chain of types between the controller and the message bus, while IMessageBus is only an intermediate step on the way.
图9.1显示了IBus和IMessageBus在六边形架构中的位置: IBus是控制器和消息总线之间类型链的最后一环,而IMessageBus只是途中的一个中间步骤。
Mocking IBus instead of IMessageBus maximizes the mock’s protection against regressions. As you might remember from chapter 4, protection against regressions is a function of the amount of code that is executed during the test. Mocking the very last type that communicates with the unmanaged dependency increases the number of classes the integration test goes through and thus improves the protection. This guideline is also the reason you don’t want to mock EventDispatcher. It resides even further away from the edge of the system, compared to IMessageBus.
mock IBus而不是IMessageBus可以最大限度地保护mock的回归。你可能还记得第四章,对回归的保护是测试中执行的代码量的函数。mock与非托管依赖关系通信的最后一个类型,可以增加集成测试所经过的类的数量,从而提高保护。这条准则也是你不想模拟EventDispatcher的原因。与IMessageBus相比,它甚至离系统的边缘更远。

Figure 9.1 IBus resides at the system’s edge; IMessageBus is only an intermediate link in the chain of types between the controller and the message bus. Mocking IBus instead of IMessageBus achieves the best protection against regressions. 图9.1 IBus位于系统的边缘;IMessageBus只是控制器和消息总线之间类型链中的一个中间环节。嘲弄IBus而不是IMessageBus可以实现对回归的最佳保护。
Here’s the integration test after retargeting it from IMessageBus to IBus. I’m omitting the parts that didn’t change from listing 9.3. 这是从IMessageBus到IBus重新定位后的集成测试。我省略了列表9.3中没有变化的部分。
Listing 9.5 Integration test targeting IBus
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
var busMock = new Mock<IBus>();
var messageBus = new MessageBus(busMock.Object); // Uses a concrete class instead of the interface
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBus, loggerMock.Object);
/* ... */
busMock.Verify( // Verifies the actual message sent to the bus
x => x.Send(
"Type: USER EMAIL CHANGED; " +
$"Id: {user.UserId}; " +
"NewEmail: new@gmail.com"),
Times.Once);
}
Notice how the test now uses the concrete MessageBus class and not the corresponding IMessageBus interface. IMessageBus is an interface with a single implementation, and, as you’ll remember from chapter 8, mocking is the only legitimate reason to have such interfaces. Because we no longer mock IMessageBus, this interface can be deleted and its usages replaced with MessageBus.
注意到测试现在是如何使用具体的MessageBus类而不是相应的IMessageBus接口的。IMessageBus是一个只有一个实现的接口,正如你在第8章中所记得的,嘲弄是拥有这种接口的唯一合法理由。因为我们不再模拟IMessageBus,这个接口可以被删除,它的用法被MessageBus所取代。
Also notice how the test in listing 9.5 checks the text message sent to the bus. Compare it to the previous version:
也注意到listing 9.5中的测试是如何检查发送到总线上的文本消息的。将其与之前的版本进行比较:
messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
Times.Once);
There’s a huge difference between verifying a call to a custom class that you wrote and the actual text sent to external systems. External systems expect text messages from your application, not calls to classes like MessageBus. In fact, text messages are the only side effect observable externally; classes that participate in producing those messages are mere implementation details. Thus, in addition to the increased protection against regressions, verifying interactions at the very edges of your system also improves resistance to refactoring. The resulting tests are less exposed to potential false positives; no matter what refactorings take place, such tests won’t turn red as long as the message’s structure is preserved.
验证对你写的自定义类的调用和实际发送到外部系统的文本之间有很大的区别。外部系统希望从你的应用程序中得到文本信息,而不是对MessageBus等类的调用。事实上,文本信息是唯一可被外部观察到的副作用;参与产生这些信息的类仅仅是实现细节。因此,除了增加对回归的保护外,在系统的边缘验证交互也能提高对重构的抵抗力。由此产生的测试较少暴露于潜在的假阳性;无论发生什么重构,只要消息的结构被保留下来,这些测试就不会变成红色。
The same mechanism is at play here as the one that gives integration and end-to-end tests additional resistance to refactoring compared to unit tests. They are more detached from the code base and, therefore, aren’t affected as much during low-level refactorings.
与单元测试相比,给集成和端到端测试提供了额外的抗重构能力,同样的机制也在这里发挥作用。它们与代码库更加分离,因此,在低级别的重构过程中不会受到太多影响。
TIP
A call to an unmanaged dependency goes through several stages before it leaves your application. Pick the last such stage. It is the best way to ensure backward compatibility with external systems, which is the goal that mocks help you achieve.
对非托管依赖的调用在离开你的应用程序之前要经过几个阶段。选择最后一个这样的阶段。这是确保与外部系统向后兼容的最好方法,这也是模拟帮助你实现的目标。
9.1.2 Replacing mocks with spies
As you may remember from chapter 5, a spy is a variation of a test double that serves the same purpose as a mock. The only difference is that spies are written manually, whereas mocks are created with the help of a mocking framework. Indeed, spies are often called handwritten mocks.
你可能还记得第五章,间谍是测试替身的一种变体,它的作用与模拟相同。唯一的区别是,spies是手动编写的,而mock是在mock框架的帮助下创建的。事实上,spies经常被称为手写的mock。
It turns out that, when it comes to classes residing at the system edges, spies are superior to mocks. Spies help you reuse code in the assertion phase, thereby reducing the test’s size and improving readability. The next listing shows an example of a spy that works on top of IBus.
事实证明,当涉及到居住在系统边缘的类时,间谍比嘲讽更有优势。监视者帮助你在断言阶段重用代码,从而减少测试的大小,提高可读性。下一个列表显示了一个在IBus之上工作的间谍的例子。
Listing 9.6 A spy (also known as a handwritten mock)
public interface IBus
{
void Send(string message);
}
public class BusSpy : IBus
{
private List<string> _sentMessages =
new List<string>(); // Stores all sent messages locally
public void Send(string message)
{
_sentMessages.Add(message);
}
public BusSpy ShouldSendNumberOfMessages(int number)
{
Assert.Equal(number, _sentMessages.Count);
return this;
}
public BusSpy WithEmailChangedMessage(int userId, string newEmail)
{
string message = "Type: USER EMAIL CHANGED; " +
$"Id: {userId}; " +
$"NewEmail: {newEmail}";
Assert.Contains(
_sentMessages, x => x == message); // Asserts that the message has been sent
return this;
}
}
The following listing is a new version of the integration test. Again, I’m showing only the relevant parts.
下面列出的是集成测试的新版本。同样,我只展示了相关的部分。
Listing 9.7 Using the spy from listing 6.43
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
var busSpy = new BusSpy();
var messageBus = new MessageBus(busSpy);
var loggerMock = new Mock<IDomainLogger>();
var sut = new UserController(db, messageBus, loggerMock.Object);
/* ... */
busSpy.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
}
Verifying the interactions with the message bus is now succinct and expressive, thanks to the fluent interface that BusSpy provides. With that fluent interface, you can chain together several assertions, thus forming cohesive, almost plain-English sentences.
由于BusSpy提供了流畅的接口,现在验证与消息总线的交互是简洁而富有表现力的。有了这个流畅的接口,你可以把几个断言连在一起,从而形成有内涵的、几乎是纯英语的句子。
TIP
You can rename BusSpy into BusMock. As I mentioned earlier, the difference between a mock and a spy is an implementation detail. Most programmers aren’t familiar with the term spy, though, so renaming the spy as BusMock can save your colleagues unnecessary confusion. 你可以把BusSpy重命名为BusMock。正如我前面提到的,模拟和间谍之间的区别是一个实现细节。不过大多数程序员并不熟悉spy这个词,所以把spy重命名为BusMock可以为你的同事省去不必要的困惑。
There’s a reasonable question to be asked here: didn’t we just make a full circle and come back to where we started? The version of the test in listing 9.7 looks a lot like the earlier version that mocked IMessageBus: 这里有一个合理的问题要问:我们不就是绕了一圈,回到了我们开始的地方吗?列表9.7中的测试版本看起来很像先前模拟IMessageBus的版本:
messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"), // Same as WithEmailChangedMessage(user.UserId, "new@gmail.com")
Times.Once); // Same as ShouldSendNumberOfMessages(1)
These assertions are similar because both BusSpy and MessageBus are wrappers on top of IBus. But there’s a crucial difference between the two: BusSpy is part of the test code, whereas MessageBus belongs to the production code. This difference is important because you shouldn’t rely on the production code when making assertions in tests.
这些断言是相似的,因为BusSpy和MessageBus都是在IBus之上的包装器。但是这两者之间有一个关键的区别:BusSpy是测试代码的一部分,而MessageBus属于生产代码。这个区别很重要,因为你在测试中做断言时不应该依赖生产代码。
Think of your tests as auditors. A good auditor wouldn’t just take the auditee’s words at face value; they would double-check everything. The same is true with the spy: it provides an independent checkpoint that raises an alarm when the message structure is changed. On the other hand, a mock on IMessageBus puts too much trust in the production code.
把你的测试看成是审计师。一个好的审计师不会只听信被审计者的话,他们会仔细检查所有的东西。间谍也是如此:它提供了一个独立的检查点,当消息结构发生变化时就会发出警报。另一方面,对IMessageBus的模拟将太多的信任放在生产代码中。
9.1.3 What about IDomainLogger?
The mock that previously verified interactions with IMessageBus is now targeted at IBus, which resides at the system’s edge. Here are the current mock assertions in the integration test.
之前验证与IMessageBus交互的模拟现在针对IBus,它驻扎在系统的边缘。下面是集成测试中当前的mock断言。
Listing 9.8 Mock assertions
busSpy.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(
user.UserId, "new@gmail.com"); // Checks interactions with IBus
loggerMock.Verify(
x => x.UserTypeHasChanged(
user.UserId,
UserType.Employee,
UserType.Customer),
Times.Once); // Checks interactions with IDomainLogger
Note that just as MessageBus is a wrapper on top of IBus, DomainLogger is a wrapper on top of ILogger (see chapter 8 for more details). Shouldn’t the test be retargeted at ILogger, too, because this interface also resides at the application boundary?
请注意,正如MessageBus是IBus上面的一个包装器,DomainLogger是ILogger上面的一个包装器(详见第8章)。由于这个接口也位于应用程序的边界,测试不是也应该在ILogger上重定向吗?
In most projects, such retargeting isn’t necessary. While the logger and the message bus are unmanaged dependencies and, therefore, both require maintaining backward compatibility, the accuracy of that compatibility doesn’t have to be the same. With the message bus, it’s important not to allow any changes to the structure of the messages, because you never know how external systems will react to such changes. But the exact structure of text logs is not that important for the intended audience (support staff and system administrators). What’s important is the existence of those logs and the information they carry. Thus, mocking IDomainLogger alone provides the necessary level of protection.
在大多数项目中,这样的重定向是没有必要的。虽然记录器和消息总线都是非管理的依赖关系,因此都需要保持向后的兼容性,但这种兼容性的准确性不一定是相同的。对于消息总线,重要的是不允许对消息的结构有任何改变,因为你永远不知道外部系统对这种改变会有什么反应。但是,文本日志的确切结构对于目标受众(支持人员和系统管理员)来说并不那么重要。重要的是这些日志的存在和它们所携带的信息。因此,仅仅模拟IDomainLogger就可以提供必要的保护。
9.2 Mocking best practices
You’ve learned two major mocking best practices so far: 到目前为止,你已经学到了两个主要的mock最佳实践:
- Applying mocks to unmanaged dependencies only 仅将mock应用于未被管理的依赖关系
- Verifying the interactions with those dependencies at the very edges of your system 在系统的最边缘验证与这些依赖关系的交互。
In this section, I explain the remaining best practices: 在本节中,我将解释其余的最佳实践:
- Using mocks in integration tests only, not in unit tests 只在集成测试中使用mock,不在单元测试中使用
- Always verifying the number of calls made to the mock 始终验证对mock的调用数量
- Mocking only types that you own 只mock你自己的类型
9.2.1 Mocks are for integration tests only
The guideline saying that mocks are for integration tests only, and that you shouldn’t use mocks in unit tests, stems from the foundational principle described in chapter 7: the separation of business logic and orchestration. Your code should either communicate with out-of-process dependencies or be complex, but never both. This principle naturally leads to the formation of two distinct layers: the domain model (that handles complexity) and controllers (that handle the communication).
说mock只适用于集成测试,不应该在单元测试中使用mock的准则,源于第7章中描述的基础原则:业务逻辑和协调的分离。你的代码要么与进程外的依赖关系进行通信,要么是复杂的,但绝不是两者都有。这一原则自然导致了两个不同层次的形成:领域模型(处理复杂性)和控制器(处理通信)。
Tests on the domain model fall into the category of unit tests; tests covering controllers are integration tests. Because mocks are for unmanaged dependencies only, and because controllers are the only code working with such dependencies, you should only apply mocking when testing controllers—in integration tests.
对领域模型的测试属于单元测试的范畴;涵盖控制器的测试属于集成测试。因为mock只适用于未管理的依赖关系,而且控制器是唯一与这种依赖关系一起工作的代码,所以你只应该在测试控制器时应用mocking—集成测试。
9.2.2 Not just one mock per test
You might sometimes hear the guideline of having only one mock per test. According to this guideline, if you have more than one mock, you are likely testing several things at a time.
有时你可能会听到每个测试只有一个mock的准则。根据这个准则,如果你有一个以上的mock,你很可能是在同时测试几个东西。
This is a misconception that follows from a more foundational misunderstanding covered in chapter 2: that a unit in a unit test refers to a unit of code, and all such units must be tested in isolation from each other. On the contrary: the term unit means a unit of behavior, not a unit of code. The amount of code it takes to implement such a unit of behavior is irrelevant. It could span across multiple classes, a single class, or take up just a tiny method.
这是一个误解,源于第二章中涉及的一个更基本的误解:单元测试中的单元是指代码的一个单元,所有这些单元必须在相互隔离的情况下进行测试。恰恰相反,单元指的是行为的单元,而不是代码的单元。实现这样一个行为单元所需的代码量是不相关的。它可以跨越多个类,也可以是一个类,或者只占用一个小方法。
With mocks, the same principle is at play: it’s irrelevant how many mocks it takes to verify a unit of behavior. Earlier in this chapter, it took us two mocks to check the scenario of changing the user email from corporate to non-corporate: one for the logger and the other for the message bus. That number could have been larger. In fact, you don’t have control over how many mocks to use in an integration test. The number of mocks depends solely on the number of unmanaged dependencies participating in the operation.
对于模拟,同样的原则也在起作用:验证一个行为单元需要多少个模拟并不重要。在本章的早些时候,我们花了两个mock来验证将用户的电子邮件从公司改为非公司的方案:一个用于记录器,另一个用于消息总线。这个数字本可以更大。事实上,你并不能控制在集成测试中使用多少个模拟。模拟的数量完全取决于参与操作的未管理的依赖关系的数量。
9.2.3 Verifying the number of calls
When it comes to communications with unmanaged dependencies, it’s important to ensure both of the following: 当涉及到与未管理的依赖关系的通信时,必须确保以下两点:
- The existence of expected calls 存在预期的调用
- The absence of unexpected calls 没有意外的调用
This requirement, once again, stems from the need to maintain backward compatibility with unmanaged dependencies. The compatibility must go both ways: your application shouldn’t omit messages that external systems expect, and it also shouldn’t produce unexpected messages. It’s not enough to check that the system under test sends a message like this:
这一要求再次源于保持与非托管依赖关系向后兼容的需要。兼容性必须是双向的:你的应用程序不应该遗漏外部系统所期望的信息,也不应该产生意外的信息。仅仅检查被测系统是否发送了这样的消息是不够的:
messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"));
You also need to ensure that this message is sent exactly once:
messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
Times.Once); // Ensures that the method is called only once
With most mocking libraries, you can also explicitly verify that no other calls are made on the mock. In Moq (the mocking library of my choice), this verification looks as follows: 在大多数模拟库中,你也可以明确地验证没有对模拟对象进行其他调用。在Moq(我选择的嘲讽库)中,这种验证看起来如下:
messageBusMock.Verify(
x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
Times.Once); // The additional check
messageBusMock.VerifyNoOtherCalls();
BusSpy implements this functionality, too: BusSpy也实现了这个功能:
busSpy
.ShouldSendNumberOfMessages(1)
.WithEmailChangedMessage(user.UserId, "new@gmail.com");
The spy’s check ShouldSendNumberOfMessages(1) encompasses both Times.Once and VerifyNoOtherCalls() verifications from the mock.
间谍的检查ShouldSendNumberOfMessages(1)包含了Times.Once和VerifyNoOtherCalls()这两个来自mock的验证。
9.2.4 Only mock types that you own
The last guideline I’d like to talk about is mocking only types that you own. It was first introduced by Steve Freeman and Nat Pryce. The guideline states that you should always write your own adapters on top of third-party libraries and mock those adapters instead of the underlying types. A few of their arguments are as follows:
我想谈的最后一条准则是只模拟你自己的类型。这条准则是由Steve Freeman和Nat Pryce首次提出的。该准则指出,你应该在第三方库的基础上编写自己的适配器,并对这些适配器而不是底层类型进行模拟。他们的几个论点如下:
- You often don’t have a deep understanding of how the third-party code works. 你往往对第三方代码的工作方式没有深入的了解。
- Even if that code already provides built-in interfaces, it’s risky to mock those interfaces, because you have to be sure the behavior you mock matches what the external library actually does. 即使这些代码已经提供了内置的接口,模拟这些接口也是有风险的,因为你必须确保你模拟的行为与外部库的实际工作相匹配。
- Adapters abstract non-essential technical details of the third-party code and define the relationship with the library in your application’s terms. 适配器抽象了第三方代码的非必要的技术细节,并以你的应用程序的术语定义与库的关系。
I fully agree with this analysis. Adapters, in effect, act as an anti-corruption layer between your code and the external world.2 These help you to
我完全同意这种分析。适配器,实际上,在你的代码和外部世界之间充当了一个反腐层。
- Abstract the underlying library’s complexity 抽取底层库的复杂性
- Only expose features you need from the library 只从库中暴露你需要的功能
- Do that using your project’s domain language 使用你的项目的领域语言来做到这一点
The IBus interface in our sample CRM project serves exactly that purpose. Even if the underlying message bus’s library provides as nice and clean an interface as IBus, you are still better off introducing your own wrapper on top of it. You never know how the third-party code will change when you upgrade the library. Such an upgrade could cause a ripple effect across the whole code base! The additional abstraction layer restricts that ripple effect to just one class: the adapter itself.
我们的CRM项目样本中的IBus接口正是为了这个目的。即使底层消息总线的库提供了像IBus那样漂亮和干净的接口,你仍然最好在它上面引入你自己的包装器。你永远不知道当你升级库的时候,第三方的代码会有什么变化。这样的升级可能会在整个代码库中引起连锁反应! 额外的抽象层将这种连锁反应限制在一个类上:适配器本身。
Note that the “mock your own types” guideline doesn’t apply to in-process dependencies. As I explained previously, mocks are for unmanaged dependencies only. Thus, there’s no need to abstract in-memory or managed dependencies. For instance, if a library provides a date and time API, you can use that API as-is, because it doesn’t reach out to unmanaged dependencies. Similarly, there’s no need to abstract an ORM as long as it’s used for accessing a database that isn’t visible to external applications. Of course, you can introduce your own wrapper on top of any library, but it’s rarely worth the effort for anything other than unmanaged dependencies.
注意,“模拟你自己的类型 “的准则并不适用于进程中的依赖关系。正如我之前所解释的,模拟只适用于未管理的依赖关系。因此,没有必要对内存中或托管的依赖关系进行抽象。例如,如果一个库提供了一个日期和时间的API,你可以按原样使用该API,因为它没有接触到未管理的依赖关系。同样地,只要ORM是用于访问外部应用程序不可见的数据库,就没有必要对它进行抽象。当然,你可以在任何库的基础上引入你自己的包装器,但除了非托管的依赖关系外,这很少值得去做。
Summary
- Verify interactions with an unmanaged dependency at the very edges of your system. Mock the last type in the chain of types between the controller and the unmanaged dependency. This helps you increase both protection against regressions (due to more code being validated by the integration test) and resistance to refactoring (due to detaching the mock from the code’s implementation details).
- Spies are handwritten mocks. When it comes to classes residing at the system’s edges, spies are superior to mocks. They help you reuse code in the assertion phase, thereby reducing the test’s size and improving readability.
- Don’t rely on production code when making assertions. Use a separate set of literals and constants in tests. Duplicate those literals and constants from the production code if necessary. Tests should provide a checkpoint independent of the production code. Otherwise, you risk producing tautology tests (tests that don’t verify anything and contain semantically meaningless assertions).
- Not all unmanaged dependencies require the same level of backward compatibility. If the exact structure of the message isn’t important, and you only want to verify the existence of that message and the information it carries, you can ignore the guideline of verifying interactions with unmanaged dependencies at the very edges of your system. The typical example is logging.
- Because mocks are for unmanaged dependencies only, and because controllers are the only code working with such dependencies, you should only apply mocking when testing controllers—in integration tests. Don’t use mocks in unit tests.
- The number of mocks used in a test is irrelevant. That number depends solely on the number of unmanaged dependencies participating in the operation.
- Ensure both the existence of expected calls and the absence of unexpected calls to mocks.
- Only mock types that you own. Write your own adapters on top of third-party libraries that provide access to unmanaged dependencies. Mock those adapters instead of the underlying types.