unit-testing-5.3-mock与测试脆弱性之间的关系

2026-05-21 ⏳4.4分钟(1.8千字)

5.3 The relationship between mocks and test fragility

5.3 mock 与测试脆弱性之间的关系

仅个人学习使用,支持正版。

书名:Unit Testing: Principles, Practices, and Patterns

The previous sections defined a mock and showed the difference between observable behavior and an implementation detail. In this section, you will learn about hexagonal architecture, the difference between internal and external communications, and (finally!) the relationship between mocks and test fragility.

前面的几节定义了 mock,并说明了可观察行为与实现细节之间的区别。本节你会了解六边形架构、内部通信与外部通信之间的差异,以及最终的主题:mock 与测试脆弱性之间的关系。

5.3.1 Defining hexagonal architecture

5.3.1 定义六边形架构

A typical application consists of two layers, domain and application services. The domain layer resides in the middle of the diagram because it’s the central part of your application. It contains the business logic: the essential functionality your application is built for. The domain layer and its business logic differentiate this application from others and provide a competitive advantage for the organization.

一个典型应用由两层组成:领域层和应用服务层。领域层位于图的中间,因为它是应用程序的核心部分。它包含业务逻辑,也就是应用程序被构建出来所要提供的核心功能。领域层及其业务逻辑将这个应用与其他应用区分开来,并为组织提供竞争优势。

Figure 5.8

The application services layer sits on top of the domain layer and orchestrates communication between that layer and the external world. For example, if your application is a RESTful API, all requests to this API hit the application services layer first. This layer then coordinates the work between domain classes and out-of-process dependencies. Here’s an example of such coordination for the application service. It does the following:

应用服务层位于领域层之上,负责协调领域层与外部世界之间的通信。例如,如果你的应用是 RESTful API,那么所有对这个 API 的请求都会先到达应用服务层。随后,这一层会协调领域类与进程外依赖之间的工作。下面是应用服务执行这种协调的例子,它会:

The combination of the application services layer and the domain layer forms a hexagon, which itself represents your application. It can interact with other applications, which are represented with their own hexagons. These other applications could be an SMTP service, a third-party system, a message bus, and so on. A set of interacting hexagons makes up a hexagonal architecture.

应用服务层和领域层组合在一起形成一个六边形,这个六边形代表你的应用程序。它可以与其他应用交互,而其他应用也由各自的六边形表示。这些其他应用可能是 SMTP 服务、第三方系统、消息总线,等等。一组相互交互的六边形构成了六边形架构。

Figure 5.9

The term hexagonal architecture was introduced by Alistair Cockburn. Its purpose is to emphasize three important guidelines:

六边形架构这个术语由 Alistair Cockburn 提出。它的目的是强调三条重要准则:

Each layer of your application exhibits observable behavior and contains its own set of implementation details. For example, observable behavior of the domain layer is the sum of this layer’s operations and state that helps the application service layer achieve at least one of its goals. The principles of a well-designed API have a fractal nature: they apply equally to as much as a whole layer or as little as a single class.

应用程序的每一层都会表现出可观察行为,并包含自己的一组实现细节。例如,领域层的可观察行为,就是这一层中帮助应用服务层达成至少一个目标的操作和状态的总和。设计良好 API 的原则具有分形性质:它们既适用于整个层,也适用于单个类。

When you make each layer’s API well-designed (that is, hide its implementation details), your tests also start to have a fractal structure; they verify behavior that helps achieve the same goals but at different levels. A test covering an application service checks to see how this service attains an overarching, coarse-grained goal posed by the external client. At the same time, a test working with a domain class verifies a subgoal that is part of that greater goal.

当你让每一层的 API 都设计良好,也就是隐藏其实现细节时,测试也会开始呈现分形结构;它们会在不同层级验证帮助达成同一目标的行为。覆盖应用服务的测试会检查该服务如何达成外部客户端提出的整体、粗粒度目标。与此同时,针对领域类的测试则验证这个更大目标中的一个子目标。

Figure 5.10

You might remember from previous chapters how I mentioned that you should be able to trace any test back to a particular business requirement. Each test should tell a story that is meaningful to a domain expert, and if it doesn’t, that’s a strong indication that the test couples to implementation details and therefore is brittle. I hope now you can see why.

你可能还记得前面章节中我提到过,你应该能够把任何测试追溯到某个具体业务需求。每个测试都应该讲述一个对领域专家有意义的故事;如果做不到,这强烈说明该测试耦合到了实现细节,因此是脆弱的。希望现在你已经能看出原因。

Observable behavior flows inward from outer layers to the center. The overarching goal posed by the external client gets translated into subgoals achieved by individual domain classes. Each piece of observable behavior in the domain layer therefore preserves the connection to a particular business use case. You can trace this connection recursively from the innermost (domain) layer outward to the application services layer and then to the needs of the external client.

可观察行为从外层向中心流动。外部客户端提出的整体目标会被翻译成由各个领域类完成的子目标。因此,领域层中的每一段可观察行为都保留着与某个具体业务用例的联系。你可以从最内层(领域层)递归地向外追踪这条联系,直到应用服务层,再到外部客户端的需求。

This traceability follows from the definition of observable behavior. For a piece of code to be part of observable behavior, it needs to help the client achieve one of its goals. For a domain class, the client is an application service; for the application service, it’s the external client itself.

这种可追溯性来自可观察行为的定义。一段代码要成为可观察行为的一部分,就必须帮助客户端达成某个目标。对领域类来说,客户端是应用服务;对应用服务来说,客户端就是外部客户端本身。

Tests that verify a code base with a well-designed API also have a connection to business requirements because those tests tie to the observable behavior only. A good example is the User and UserController classes from listing 5.6.

验证设计良好 API 的测试,也会与业务需求建立联系,因为这些测试只绑定到可观察行为。一个很好的例子是清单 5.6 中的 UserUserController 类。

Listing 5.8 A domain class with an application service

public class User
{
    private string _name;

    public string Name
    {
        get => _name;
        set => _name = NormalizeName(value);
    }

    private string NormalizeName(string name)
    {
        /* Trim name down to 50 characters */
    }
}

public class UserController
{
    public void RenameUser(int userId, string newName)
    {
        User user = GetUserFromDatabase(userId);
        user.Name = newName;
        SaveUserToDatabase(user);
    }
}

UserController in this example is an application service. Assuming that the external client doesn’t have a specific goal of normalizing user names, and all names are normalized solely due to restrictions from the application itself, the NormalizeName method in the User class can’t be traced to the client’s needs. Therefore, it’s an implementation detail and should be made private. Moreover, tests shouldn’t check this method directly. They should verify it only as part of the class’s observable behavior—the Name property’s setter in this example.

这个例子中的 UserController 是一个应用服务。假设外部客户端并没有“规范化用户名称”这个具体目标,所有名称规范化都只是由于应用自身限制而进行,那么 User 类中的 NormalizeName 方法就无法追溯到客户端需求。因此,它是实现细节,应该设为私有。此外,测试不应该直接检查这个方法。测试应该只把它作为类可观察行为的一部分来验证——在这个例子中,也就是 Name 属性的 setter。

This guideline of always tracing the code base’s public API to business requirements applies to the vast majority of domain classes and application services but less so to utility and infrastructure code. The individual problems such code solves are often too low-level and fine-grained and can’t be traced to a specific business use case.

“始终把代码库的公共 API 追溯到业务需求”这条准则,适用于绝大多数领域类和应用服务,但不太适用于工具代码和基础设施代码。这类代码解决的单个问题通常太底层、太细粒度,无法追溯到某个具体业务用例。

5.3.2 Intra-system vs. inter-system communications

5.3.2 系统内通信 vs. 系统间通信

There are two types of communications in a typical application: intra-system and inter-system. Intra-system communications are communications between classes inside your application. Inter-system communications are when your application talks to other applications.

典型应用中有两类通信:系统内通信和系统间通信。系统内通信是应用内部类之间的通信。系统间通信是你的应用与其他应用之间的通信。

Figure 5.11

NOTE Intra-system communications are implementation details; inter-system communications are not.

注意 系统内通信是实现细节;系统间通信不是。

Intra-system communications are implementation details because the collaborations your domain classes go through in order to perform an operation are not part of their observable behavior. These collaborations don’t have an immediate connection to the client’s goal. Thus, coupling to such collaborations leads to fragile tests.

系统内通信是实现细节,因为领域类为了执行某个操作而经历的协作过程,并不是它们可观察行为的一部分。这些协作与客户端目标没有直接联系。因此,耦合到这类协作会导致脆弱测试。

Inter-system communications are a different matter. Unlike collaborations between classes inside your application, the way your system talks to the external world forms the observable behavior of that system as a whole. It’s part of the contract your application must hold at all times.

系统间通信则不同。与应用内部类之间的协作不同,系统与外部世界通信的方式构成了整个系统的可观察行为。它是应用程序必须始终保持的契约的一部分。

Figure 5.12

This attribute of inter-system communications stems from the way separate applications evolve together. One of the main principles of such an evolution is maintaining backward compatibility. Regardless of the refactorings you perform inside your system, the communication pattern it uses to talk to external applications should always stay in place, so that external applications can understand it. For example, messages your application emits on a bus should preserve their structure, the calls issued to an SMTP service should have the same number and type of parameters, and so on.

系统间通信的这个属性,来自独立应用共同演化的方式。这种演化的主要原则之一是保持向后兼容性。无论你在系统内部做了什么重构,它与外部应用通信时使用的通信模式都应该保持不变,这样外部应用才能理解它。例如,你的应用发送到消息总线上的消息应该保持结构不变,对 SMTP 服务发出的调用应该保持相同数量和类型的参数,等等。

The use of mocks is beneficial when verifying the communication pattern between your system and external applications. Conversely, using mocks to verify communications between classes inside your system results in tests that couple to implementation details and therefore fall short of the resistance-to-refactoring metric.

当你验证系统与外部应用之间的通信模式时,使用 mock 是有益的。相反,如果用 mock 验证系统内部类之间的通信,就会导致测试耦合到实现细节,因此在抵抗重构这个指标上表现不足。

5.3.3 Intra-system vs. inter-system communications: An example

5.3.3 系统内通信 vs. 系统间通信:示例

To illustrate the difference between intra-system and inter-system communications, I’ll expand on the example with the Customer and Store classes that I used in chapter 2 and earlier in this chapter. Imagine the following business use case:

为了说明系统内通信和系统间通信之间的区别,我会扩展第 2 章和本章前面使用过的 CustomerStore 类示例。想象下面这个业务用例:

Let’s also assume that the application is an API with no user interface.

同时假设这个应用是一个没有用户界面的 API。

In the following listing, the CustomerController class is an application service that orchestrates the work between domain classes (Customer, Product, Store) and the external application (EmailGateway, which is a proxy to an SMTP service).

在下面的代码清单中,CustomerController 类是一个应用服务,它负责协调领域类(CustomerProductStore)与外部应用(EmailGateway,它是 SMTP 服务的代理)之间的工作。

Listing 5.9 Connecting the domain model with external applications

public class CustomerController
{
    public bool Purchase(int customerId, int productId, int quantity)
    {
        Customer customer = _customerRepository.GetById(customerId);
        Product product = _productRepository.GetById(productId);
        bool isSuccess = customer.Purchase(
            _mainStore, product, quantity);

        if (isSuccess)
        {
            _emailGateway.SendReceipt(
                customer.Email, product.Name, quantity);
        }

        return isSuccess;
    }
}

Validation of input parameters is omitted for brevity. In the Purchase method, the customer checks to see if there’s enough inventory in the store and, if so, decreases the product amount.

为了简洁起见,这里省略了输入参数验证。在 Purchase 方法中,客户会检查商店里是否有足够库存,如果有,则减少商品数量。

The act of making a purchase is a business use case with both intra-system and inter-system communications. The inter-system communications are those between the CustomerController application service and the two external systems: the third-party application (which is also the client initiating the use case) and the email gateway. The intra-system communication is between the Customer and the Store domain classes.

购买行为是一个同时包含系统内通信和系统间通信的业务用例。系统间通信发生在 CustomerController 应用服务与两个外部系统之间:第三方应用(它也是发起该用例的客户端)和邮件网关。系统内通信则发生在 CustomerStore 这两个领域类之间。

Figure 5.13

In this example, the call to the SMTP service is a side effect that is visible to the external world and thus forms the observable behavior of the application as a whole. It also has a direct connection to the client’s goals. The client of the application is the third-party system. This system’s goal is to make a purchase, and it expects the customer to receive a confirmation email as part of the successful outcome.

在这个例子中,对 SMTP 服务的调用是一个对外部世界可见的副作用,因此构成了整个应用程序的可观察行为。它也与客户端目标有直接联系。应用程序的客户端是第三方系统。这个系统的目标是完成一次购买,并期望客户在成功结果中收到一封确认邮件。

The call to the SMTP service is a legitimate reason to do mocking. It doesn’t lead to test fragility because you want to make sure this type of communication stays in place even after refactoring. The use of mocks helps you do exactly that.

对 SMTP 服务的调用是使用 mock 的正当理由。它不会导致测试脆弱,因为你确实希望确保这种通信即使在重构之后也保持存在。使用 mock 正好可以帮助你做到这一点。

The next listing shows an example of a legitimate use of mocks.

下面的代码清单展示了 mock 的一种合理用法。

Listing 5.10 Mocking that doesn’t lead to fragile tests

[Fact]
public void Successful_purchase()
{
    var mock = new Mock<IEmailGateway>();
    var sut = new CustomerController(mock.Object);

    bool isSuccess = sut.Purchase(
        customerId: 1, productId: 2, quantity: 5);

    Assert.True(isSuccess);
    mock.Verify(
        x => x.SendReceipt(
            "customer@email.com", "Shampoo", 5),
        Times.Once);
}

Note that the isSuccess flag is also observable by the external client and also needs verification. This flag doesn’t need mocking, though; a simple value comparison is enough.

注意,isSuccess 标志对外部客户端也是可观察的,也需要验证。不过这个标志不需要 mock;简单的值比较就足够了。

Let’s now look at a test that mocks the communication between Customer and Store.

现在来看一个 mock 了 CustomerStore 之间通信的测试。

Listing 5.11 Mocking that leads to fragile tests

[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .Returns(true);

    var customer = new Customer();

    bool success = customer.Purchase(
        storeMock.Object, Product.Shampoo, 5);

    Assert.True(success);
    storeMock.Verify(
        x => x.RemoveInventory(Product.Shampoo, 5),
        Times.Once);
}

Unlike the communication between CustomerController and the SMTP service, the RemoveInventory() method call from Customer to Store doesn’t cross the application boundary: both the caller and the recipient reside inside the application. Also, this method is neither an operation nor a state that helps the client achieve its goals. The client of these two domain classes is CustomerController with the goal of making a purchase. The only two members that have an immediate connection to this goal are customer.Purchase() and store.GetInventory(). The Purchase() method initiates the purchase, and GetInventory() shows the state of the system after the purchase is completed. The RemoveInventory() method call is an intermediate step on the way to the client’s goal—an implementation detail.

CustomerController 和 SMTP 服务之间的通信不同,从 CustomerStoreRemoveInventory() 方法调用并没有跨越应用边界:调用者和接收者都位于应用内部。此外,这个方法既不是帮助客户端达成目标的操作,也不是帮助客户端达成目标的状态。这两个领域类的客户端是 CustomerController,它的目标是完成购买。与这个目标有直接联系的成员只有两个:customer.Purchase()store.GetInventory()Purchase() 方法发起购买,GetInventory() 显示购买完成后的系统状态。RemoveInventory() 方法调用只是通往客户端目标过程中的一个中间步骤——也就是实现细节。