unit-testing-5.2-可观察行为vs实现细节

2026-05-21 ⏳5.2分钟(2.1千字)

5.2 Observable behavior vs. implementation details

5.2 可观察行为 vs. 实现细节

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

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

Section 5.1 showed what a mock is. The next step on the way to explaining the connection between mocks and test fragility is diving into what causes such fragility.

5.1 节说明了 mock 是什么。要解释 mock 与测试脆弱性之间的联系,下一步需要深入理解这种脆弱性产生的原因。

As you might remember from chapter 4, test fragility corresponds to the second attribute of a good unit test: resistance to refactoring. The metric of resistance to refactoring is the most important because whether a unit test possesses this metric is mostly a binary choice. Thus, it’s good to max out this metric to the extent that the test still remains in the realm of unit testing and doesn’t transition to the category of end-to-end testing. The latter, despite being the best at resistance to refactoring, is generally much harder to maintain.

你可能还记得第 4 章讲过,测试脆弱性对应优秀单元测试的第二个属性:抵抗重构。抵抗重构这个指标最重要,因为一个单元测试是否具备这个指标基本上是二元选择。因此,在测试仍然属于单元测试、还没有变成端到端测试的前提下,最好尽可能最大化这个指标。端到端测试虽然最擅长抵抗重构,但通常更难维护。

In chapter 4, you also saw that the main reason tests deliver false positives (and thus fail at resistance to refactoring) is because they couple to the code’s implementation details. The only way to avoid such coupling is to verify the end result the code produces (its observable behavior) and distance tests from implementation details as much as possible. In other words, tests must focus on the whats, not the hows. So, what exactly is an implementation detail, and how is it different from an observable behavior?

第 4 章还讲到,测试产生假阳性(从而在抵抗重构上失败)的主要原因,是它们耦合到了代码的实现细节。避免这种耦合的唯一方法,是验证代码产生的最终结果,也就是它的可观察行为,并让测试尽可能远离实现细节。换句话说,测试必须关注“做什么”,而不是“怎么做”。那么,实现细节到底是什么?它和可观察行为有什么区别?

5.2.1 Observable behavior is not the same as a public API

5.2.1 可观察行为并不等同于公共 API

All production code can be categorized along two dimensions:

所有生产代码都可以沿两个维度分类:

The categories in these dimensions don’t overlap. A method can’t belong to both a public and a private API; it’s either one or the other. Similarly, the code is either an internal implementation detail or part of the system’s observable behavior, but not both.

这些维度中的类别不会重叠。一个方法不可能同时属于公共 API 和私有 API;它只能属于其中之一。类似地,代码要么是内部实现细节,要么是系统可观察行为的一部分,但不能两者都是。

Most programming languages provide a simple mechanism to differentiate between the code base’s public and private APIs. For example, in C#, you can mark any member in a class with the private keyword, and that member will be hidden from the client code, becoming part of the class’s private API. The same is true for classes: you can easily make them private by using the private or internal keyword.

大多数编程语言都提供了简单机制,用来区分代码库的公共 API 和私有 API。例如在 C# 中,你可以用 private 关键字标记类中的任何成员,这个成员就会对客户端代码隐藏,成为该类私有 API 的一部分。类也是如此:你可以用 privateinternal 关键字轻松让它们变成私有。

The distinction between observable behavior and internal implementation details is more nuanced. For a piece of code to be part of the system’s observable behavior, it has to do one of the following things:

可观察行为与内部实现细节之间的区分则更微妙。一段代码要成为系统可观察行为的一部分,必须满足以下条件之一:

Any code that does neither of these two things is an implementation detail.

任何不满足这两点的代码,都是实现细节。

Notice that whether the code is observable behavior depends on who its client is and what the goals of that client are. In order to be a part of observable behavior, the code needs to have an immediate connection to at least one such goal. The word client can refer to different things depending on where the code resides. The common examples are client code from the same code base, an external application, or the user interface.

注意,一段代码是否属于可观察行为,取决于它的客户端是谁,以及这个客户端的目标是什么。要成为可观察行为的一部分,这段代码必须与至少一个客户端目标有直接联系。client 这个词会根据代码所在位置指代不同对象。常见例子包括同一代码库中的客户端代码、外部应用程序,或用户界面。

Ideally, the system’s public API surface should coincide with its observable behavior, and all its implementation details should be hidden from the eyes of the clients. Such a system has a well-designed API.

理想情况下,系统的公共 API 表面应该与它的可观察行为一致,所有实现细节都应该对客户端隐藏。这样的系统拥有设计良好的 API。

Figure 5.4

Often, though, the system’s public API extends beyond its observable behavior and starts exposing implementation details. Such a system’s implementation details leak to its public API surface.

不过,系统的公共 API 经常会超出可观察行为的范围,开始暴露实现细节。这种系统的实现细节就泄漏到了公共 API 表面。

Figure 5.5

5.2.2 Leaking implementation details: An example with an operation

5.2.2 实现细节泄漏:操作示例

Let’s take a look at examples of code whose implementation details leak to the public API. Listing 5.5 shows a User class with a public API that consists of two members: a Name property and a NormalizeName() method. The class also has an invariant: users’ names must not exceed 50 characters and should be truncated otherwise.

我们来看一些实现细节泄漏到公共 API 的代码例子。清单 5.5 展示了一个 User 类,它的公共 API 由两个成员组成:Name 属性和 NormalizeName() 方法。这个类还有一个不变量:用户名称不能超过 50 个字符,否则应该被截断。

Listing 5.5 User class with leaking implementation details

public class User
{
    public string Name { get; set; }

    public string NormalizeName(string name)
    {
        string result = (name ?? "").Trim();
        if (result.Length > 50)
            return result.Substring(0, 50);
        return result;
    }
}

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

UserController is client code. It uses the User class in its RenameUser method. The goal of this method, as you have probably guessed, is to change a user’s name.

UserController 是客户端代码。它在 RenameUser 方法中使用 User 类。你可能已经猜到了,这个方法的目标是修改用户名称。

So, why isn’t User’s API well-designed? Look at its members once again: the Name property and the NormalizeName method. Both of them are public. Therefore, in order for the class’s API to be well-designed, these members should be part of the observable behavior. This, in turn, requires them to do one of the following two things:

那么,为什么 User 的 API 设计不好?再次看看它的成员:Name 属性和 NormalizeName 方法。它们都是公共的。因此,要让这个类的 API 设计良好,这些成员就应该是可观察行为的一部分。这又要求它们满足下面两点之一:

Only the Name property meets this requirement. It exposes a setter, which is an operation that allows UserController to achieve its goal of changing a user’s name. The NormalizeName method is also an operation, but it doesn’t have an immediate connection to the client’s goal. The only reason UserController calls this method is to satisfy the invariant of User. NormalizeName is therefore an implementation detail that leaks to the class’s public API.

只有 Name 属性满足这个要求。它暴露了 setter,而 setter 是一个操作,允许 UserController 达成修改用户名称的目标。NormalizeName 方法也是一个操作,但它与客户端目标没有直接联系。UserController 调用这个方法的唯一原因,是为了满足 User 的不变量。因此,NormalizeName 是一个泄漏到类公共 API 的实现细节。

Figure 5.6

To fix the situation and make the class’s API well-designed, User needs to hide NormalizeName() and call it internally as part of the property’s setter without relying on the client code to do so. Listing 5.6 shows this approach.

要修复这种情况并让类的 API 设计良好,User 需要隐藏 NormalizeName(),并在属性 setter 内部调用它,而不是依赖客户端代码来调用。清单 5.6 展示了这种做法。

Listing 5.6 A version of User with a well-designed API

public class User
{
    private string _name;

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

    private string NormalizeName(string name)
    {
        string result = (name ?? "").Trim();
        if (result.Length > 50)
            return result.Substring(0, 50);
        return result;
    }
}

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

User’s API in listing 5.6 is well-designed: only the observable behavior (the Name property) is made public, while the implementation details (the NormalizeName method) are hidden behind the private API.

清单 5.6 中 User 的 API 设计良好:只有可观察行为(Name 属性)是公共的,而实现细节(NormalizeName 方法)隐藏在私有 API 后面。

Figure 5.7

NOTE Strictly speaking, Name’s getter should also be made private, because it’s not used by UserController. In reality, though, you almost always want to read back changes you make. Therefore, in a real project, there will certainly be another use case that requires seeing users’ current names via Name’s getter.

注意 严格来说,Name 的 getter 也应该设为私有,因为 UserController 并没有使用它。不过在现实中,你几乎总是希望能读回自己做出的修改。因此在真实项目中,肯定会存在另一个用例,需要通过 Name 的 getter 查看用户当前名称。

There’s a good rule of thumb that can help you determine whether a class leaks its implementation details. If the number of operations the client has to invoke on the class to achieve a single goal is greater than one, then that class is likely leaking implementation details. Ideally, any individual goal should be achieved with a single operation.

有一条很好用的经验法则,可以帮助你判断一个类是否泄漏了实现细节。如果客户端为了达成单一目标,必须在这个类上调用的操作数量大于一个,那么这个类很可能泄漏了实现细节。理想情况下,任何单一目标都应该通过一个操作完成。

In listing 5.5, for example, UserController has to use two operations from User:

例如,在清单 5.5 中,UserController 必须使用 User 的两个操作:

string normalizedName = user.NormalizeName(newName);
user.Name = normalizedName;

After the refactoring, the number of operations has been reduced to one:

重构之后,操作数量减少为一个:

user.Name = newName;

In my experience, this rule of thumb holds true for the vast majority of cases where business logic is involved. There could very well be exceptions, though. Still, be sure to examine each situation where your code violates this rule for a potential leak of implementation details.

根据我的经验,在涉及业务逻辑的绝大多数情况下,这条经验法则都成立。当然,也可能存在例外。即便如此,只要你的代码违反了这条规则,都应该检查是否存在潜在的实现细节泄漏。

5.2.3 Well-designed API and encapsulation

5.2.3 设计良好的 API 与封装

Maintaining a well-designed API relates to the notion of encapsulation. As you might recall from chapter 3, encapsulation is the act of protecting your code against inconsistencies, also known as invariant violations. An invariant is a condition that should be held true at all times. The User class from the previous example had one such invariant: no user could have a name that exceeded 50 characters.

维护设计良好的 API 与封装概念有关。你可能还记得第 3 章讲过,封装是保护代码免受不一致影响的行为,也就是避免不变量被破坏。不变量是一个任何时候都应该保持为真的条件。前面例子中的 User 类就有这样一个不变量:任何用户名称都不能超过 50 个字符。

Exposing implementation details goes hand in hand with invariant violations—the former often leads to the latter. Not only did the original version of User leak its implementation details, but it also didn’t maintain proper encapsulation. It allowed the client to bypass the invariant and assign a new name to a user without normalizing that name first.

暴露实现细节通常会伴随不变量破坏——前者经常导致后者。User 的原始版本不仅泄漏了实现细节,也没有维护适当封装。它允许客户端绕过不变量,在没有先规范化名称的情况下,直接给用户赋予新名称。

Encapsulation is crucial for code base maintainability in the long run. The reason why is complexity. Code complexity is one of the biggest challenges you’ll face in software development. The more complex the code base becomes, the harder it is to work with, which, in turn, results in slowing down development speed and increasing the number of bugs.

从长期来看,封装对代码库可维护性至关重要。原因在于复杂度。代码复杂度是软件开发中你会遇到的最大挑战之一。代码库越复杂,使用它就越困难,这又会导致开发速度下降、缺陷数量增加。

Without encapsulation, you have no practical way to cope with ever-increasing code complexity. When the code’s API doesn’t guide you through what is and what isn’t allowed to be done with that code, you have to keep a lot of information in mind to make sure you don’t introduce inconsistencies with new code changes. This brings an additional mental burden to the process of programming. Remove as much of that burden from yourself as possible. You cannot trust yourself to do the right thing all the time—so, eliminate the very possibility of doing the wrong thing. The best way to do so is to maintain proper encapsulation so that your code base doesn’t even provide an option for you to do anything incorrectly. Encapsulation ultimately serves the same goal as unit testing: it enables sustainable growth of your software project.

没有封装,你就没有实际办法应对不断增长的代码复杂度。当代码的 API 不会引导你理解什么可以做、什么不可以做时,你就必须在脑中记住大量信息,确保新的代码修改不会引入不一致。这会给编程过程带来额外的心智负担。你应该尽可能把这种负担从自己身上移除。你不能相信自己每次都会做对事情——所以,要消除做错事情的可能性。最好的方式是维护适当封装,让代码库甚至不给你提供错误操作的选项。封装最终服务于与单元测试相同的目标:支持软件项目的可持续增长。

There’s a similar principle: tell-don’t-ask. It was coined by Martin Fowler and stands for bundling data with the functions that operate on that data. You can view this principle as a corollary to the practice of encapsulation. Code encapsulation is a goal, whereas bundling data and functions together, as well as hiding implementation details, are the means to achieve that goal:

还有一个类似原则:Tell, Don’t Ask(告诉,不要询问)。这个原则由 Martin Fowler 提出,指的是把数据与操作这些数据的函数绑定在一起。你可以把这个原则看作封装实践的推论。代码封装是目标,而把数据与函数放在一起、隐藏实现细节,是实现这个目标的手段:

5.2.4 Leaking implementation details: An example with state

5.2.4 实现细节泄漏:状态示例

The example shown in listing 5.5 demonstrated an operation (the NormalizeName method) that was an implementation detail leaking to the public API. Let’s also look at an example with state. The following listing contains the MessageRenderer class you saw in chapter 4. It uses a collection of sub-renderers to generate an HTML representation of a message containing a header, a body, and a footer.

清单 5.5 中的例子展示了一个作为实现细节却泄漏到公共 API 的操作(NormalizeName 方法)。我们再看一个状态方面的例子。下面的代码清单包含你在第 4 章见过的 MessageRenderer 类。它使用一个子渲染器集合,为包含头部、正文和页脚的消息生成 HTML 表示。

Listing 5.7 State as an implementation detail

public class MessageRenderer : IRenderer
{
    public IReadOnlyList<IRenderer> SubRenderers { get; }

    public MessageRenderer()
    {
        SubRenderers = new List<IRenderer>
        {
            new HeaderRenderer(),
            new BodyRenderer(),
            new FooterRenderer()
        };
    }

    public string Render(Message message)
    {
        return SubRenderers
            .Select(x => x.Render(message))
            .Aggregate("", (str1, str2) => str1 + str2);
    }
}

The sub-renderers collection is public. But is it part of observable behavior? Assuming that the client’s goal is to render an HTML message, the answer is no. The only class member such a client would need is the Render method itself. Thus SubRenderers is also a leaking implementation detail.

子渲染器集合是公共的。但它是可观察行为的一部分吗?假设客户端的目标是渲染一条 HTML 消息,答案是否定的。这样的客户端唯一需要的类成员是 Render 方法本身。因此,SubRenderers 也是一个泄漏出来的实现细节。

I bring up this example again for a reason. As you may remember, I used it to illustrate a brittle test. That test was brittle precisely because it was tied to this implementation detail—it checked to see the collection’s composition. The brittleness was fixed by re-targeting the test at the Render method. The new version of the test verified the resulting message—the only output the client code cared about, the observable behavior.

我再次提到这个例子是有原因的。你可能还记得,我曾用它说明脆弱测试。那个测试之所以脆弱,正是因为它绑定到了这个实现细节——它检查集合的组成。修复脆弱性的方式,是把测试重新指向 Render 方法。新版测试验证的是最终消息,也就是客户端代码唯一关心的输出,即可观察行为。

As you can see, there’s an intrinsic connection between good unit tests and a well-designed API. By making all implementation details private, you leave your tests no choice other than to verify the code’s observable behavior, which automatically improves their resistance to refactoring.

可以看到,优秀单元测试与设计良好的 API 之间存在内在联系。通过把所有实现细节设为私有,你会让测试别无选择,只能验证代码的可观察行为,这会自动提升测试的抵抗重构能力。

TIP Making the API well-designed automatically improves unit tests.

提示 让 API 设计良好,会自动改善单元测试。

Another guideline flows from the definition of a well-designed API: you should expose the absolute minimum number of operations and state. Only code that directly helps clients achieve their goals should be made public. Everything else is implementation details and thus must be hidden behind the private API.

从设计良好 API 的定义还可以推出另一条准则:你应该暴露绝对最少数量的操作和状态。只有那些直接帮助客户端达成目标的代码才应该设为公共。其他所有内容都是实现细节,因此必须隐藏在私有 API 后面。

Note that there’s no such problem as leaking observable behavior, which would be symmetric to the problem of leaking implementation details. While you can expose an implementation detail, you can’t hide an observable behavior. Such a method or class would no longer have an immediate connection to the client goals, because the client wouldn’t be able to directly use it anymore. Thus, by definition, this code would cease to be part of observable behavior.

注意,并不存在“可观察行为泄漏”这种与“实现细节泄漏”对称的问题。你可以暴露一个实现细节,但不能隐藏一个可观察行为。这样的方法或类将不再与客户端目标有直接联系,因为客户端无法再直接使用它。因此,根据定义,这段代码就不再是可观察行为的一部分。

Table 5.1