NoBug World
Hi, NoBug World

单元测试原则、实践与模式(十一)

单元测试

译者声明:本译文仅作为学习的记录,不作为商业用途,如有侵权请告知我删除。
本文禁止转载!
请支持原书正版:https://www.manning.com/books/unit-testing

image-20230409174007732

This chapter covers

  • Unit testing private methods 单元测试私有方法
  • Exposing private state to enable unit testing 暴露私有状态以实现单元测试
  • Leaking domain knowledge to tests 将领域知识泄露给测试
  • Mocking concrete classes mock具体的类

This chapter is an aggregation of lesser related topics (mostly anti-patterns) that didn’t fit in earlier in the book and are better served on their own. An anti-pattern is a common solution to a recurring problem that looks appropriate on the surface but leads to problems further down the road.

本章是一些不太相关的主题(主要是反模式)的汇总,这些主题在本书的前面部分不适合,最好是单独使用。反模式是一种解决重复出现的问题的常见方法,表面上看起来很合适,但会导致更多的问题。

You will learn how to work with time in tests, how to identify and avoid such antipatterns as unit testing of private methods, code pollution, mocking concrete classes, and more. Most of these topics follow from the first principles described in part 2. Still, they are well worth spelling out explicitly. You’ve probably heard of at least some of these anti-patterns in the past, but this chapter will help you connect the dots, so to speak, and see the foundations they are based on.

你将学习如何在测试中与时间打交道,如何识别和避免诸如私有方法的单元测试、代码污染、mock具体类等反模式。这些主题中的大多数都来自于第二部分中描述的第一个原则。不过,它们还是很值得明确说明的。你可能在过去至少听说过其中的一些反模式,但本章将帮助你把这些点联系起来,可以说是看到它们所基于的基础。

11.1 Unit testing private methods

When it comes to unit testing, one of the most commonly asked questions is how to test a private method. The short answer is that you shouldn’t do so at all, but there’s quite a bit of nuance to this topic.

谈到单元测试,最常问的问题之一是如何测试私有方法。简短的回答是,你根本就不应该这么做,但这个话题有相当多的细微差别。

11.1.1 Private methods and test fragility

Exposing methods that you would otherwise keep private just to enable unit testing violates one of the foundational principles we discussed in chapter 5: testing observable behavior only. Exposing private methods leads to coupling tests to implementation details and, ultimately, damaging your tests’ resistance to refactoring—the most important metric of the four. (All four metrics, once again, are protection against regressions, resistance to refactoring, fast feedback, and maintainability.) Instead of testing private methods directly, test them indirectly, as part of the overarching observable behavior.

为了进行单元测试而公开你本应保持私有的方法,这违反了我们在第五章讨论的基本原则之一:只测试可观察的行为。暴露私有方法会导致测试与实现细节的耦合,并最终破坏你的测试对重构的抵抗力—这是四个指标中最重要的。(所有的四个指标,再一次,是对回归的保护,对重构的抵抗,快速反馈和可维护性)。与其直接测试私有方法,不如间接地测试它们,作为总体可观察行为的一部分。

11.1.2 Private methods and insufficient coverage

Sometimes, the private method is too complex, and testing it as part of the observable behavior doesn’t provide sufficient coverage. Assuming the observable behavior already has reasonable test coverage, there can be two issues at play:

有时,私有方法过于复杂,而将其作为可观察行为的一部分进行测试并不能提供足够的覆盖。假设可观察行为已经有了合理的测试覆盖率,那么可能有两个问题在起作用:

  • This is dead code. If the uncovered code isn’t being used, this is likely some extraneous code left after a refactoring. It’s best to delete this code. 这是死的代码。如果未被发现的代码没有被使用,这可能是重构后留下的一些无关紧要的代码。最好是删除这些代码。
  • There’s a missing abstraction. If the private method is too complex (and thus is hard to test via the class’s public API), it’s an indication of a missing abstraction that should be extracted into a separate class. 有一个缺失的抽象概念。如果这个私有方法过于复杂(因此很难通过类的公共API进行测试),这就表明有一个缺失的抽象,应该被提取到一个单独的类中。

Let’s illustrate the second issue with an example. 让我们用一个例子来说明第二个问题。

Listing 11.1 A class with a complex private method

public class Order
{
    private Customer _customer;
    private List<Product> _products;

    public string GenerateDescription()
    {
        return $"Customer name: {_customer.Name}, " +
            $"total number of products: {_products.Count}, " +
            $"total price: {GetPrice()}";   ❶
    }

    private decimal GetPrice()   ❷
    {
        decimal basePrice = /* Calculate based on _products */;
        decimal discounts = /* Calculate based on _customer */;
        decimal taxes = /* Calculate based on _products */;
        return basePrice - discounts + taxes;
    }
}

The GenerateDescription() method is quite simple: it returns a generic description of the order. But it uses the private GetPrice() method, which is much more complex: it contains important business logic and needs to be thoroughly tested. That logic is a missing abstraction. Instead of exposing the GetPrice method, make this abstraction explicit by extracting it into a separate class, as shown in the next listing.

GenerateDescription()方法很简单:它返回订单的一般描述。但是它使用了私有的GetPrice()方法,这个方法要复杂得多:它包含了重要的商业逻辑,需要彻底测试。这个逻辑是一个缺失的抽象。与其暴露GetPrice方法,不如把它提取到一个单独的类中,使这个抽象显性化,如下一个列表所示。

Listing 11.2 Extracting the complex private method

public class Order
{
    private Customer _customer;
    private List<Product> _products;

    public string GenerateDescription()
    {
        var calc = new PriceCalculator();

        return $"Customer name: {_customer.Name}, " +
            $"total number of products: {_products.Count}, " +
            $"total price: {calc.Calculate(_customer, _products)}";
    }
}

public class PriceCalculator
{
    public decimal Calculate(Customer customer, List<Product> products)
    {
        decimal basePrice = /* Calculate based on products */;
        decimal discounts = /* Calculate based on customer */;
        decimal taxes = /* Calculate based on products */;
        return basePrice - discounts + taxes;
    }
}

Now you can test PriceCalculator independently of Order. You can also use the output-based (functional) style of unit testing, because PriceCalculator doesn’t have any hidden inputs or outputs. See chapter 6 for more information about styles of unit testing.

现在你可以独立于订单测试PriceCalculator。你也可以使用基于输出(功能)的单元测试风格,因为PriceCalculator没有任何隐藏的输入或输出。更多关于单元测试风格的信息,请参见第6章。

11.1.3 When testing private methods is acceptable

There are exceptions to the rule of never testing private methods. To understand those exceptions, we need to revisit the relationship between the code’s publicity and purpose from chapter 5. Table 11.1 sums up that relationship (you already saw this table in chapter 5; I’m copying it here for convenience).

绝不测试私有方法的规则也有例外。为了理解这些例外情况,我们需要重新审视第五章中代码的宣传和目的之间的关系。表11.1总结了这种关系(你已经在第5章看到了这个表,为了方便起见,我把它复制到这里)。

Table 11.1 The relationship between the code’s publicity and purpose

Observable behaviorImplementation detail
PublicGoodBad
PrivateN/AGood

As you might remember from chapter 5, making the observable behavior public and implementation details private results in a well-designed API. On the other hand, leaking implementation details damages the code’s encapsulation. The intersection of observable behavior and private methods is marked N/A in the table because for a method to become part of observable behavior, it has to be used by the client code, which is impossible if that method is private.

你可能还记得第五章的内容,将可观察的行为公开,而将实现的细节保密,这样就可以得到一个设计良好的API。另一方面,泄露实现细节会破坏代码的封装性。可观察行为和私有方法的交叉点在表格中被标记为不适用,因为要使一个方法成为可观察行为的一部分,它必须被客户代码使用,如果该方法是私有的,这是不可能的。

Note that testing private methods isn’t bad in and of itself. It’s only bad because those private methods are a proxy for implementation details. Testing implementation details is what ultimately leads to test brittleness. Having that said, there are rare cases where a method is both private and part of observable behavior (and thus the N/A marking in table 11.1 isn’t entirely correct).

请注意,测试私有方法本身并不坏。它之所以不好,只是因为这些私有方法是实现细节的代理。测试实现细节是最终导致测试变脆的原因。话虽如此,但在极少数情况下,一个方法既是私有的又是可观察行为的一部分(因此表11.1中的N/A标记并不完全正确)。

Let’s take a system that manages credit inquiries as an example. New inquiries are bulk-loaded directly into the database once a day. Administrators then review those inquiries one by one and decide whether to approve them. Here’s how the Inquiry class might look in that system.

让我们以一个管理信用查询的系统为例。新的查询被直接批量载入数据库,每天一次。管理员然后逐一审查这些查询,并决定是否批准它们。下面是查询类在该系统中的样子。

Listing 11.3 A class with a private constructor

public class Inquiry
{
    public bool IsApproved { get; private set; }
    public DateTime? TimeApproved { get; private set; }

    private Inquiry(                               ❶
        bool isApproved, DateTime? timeApproved)   ❶
    {
        if (isApproved && !timeApproved.HasValue)
            throw new Exception();

        IsApproved = isApproved;
        TimeApproved = timeApproved;
    }

    public void Approve(DateTime now)
    {
        if (IsApproved)
            return;

        IsApproved = true;
        TimeApproved = now;
    }
}

The private constructor is private because the class is restored from the database by an object-relational mapping (ORM) library. That ORM doesn’t need a public constructor; it may well work with a private one. At the same time, our system doesn’t need a constructor, either, because it’s not responsible for the creation of those inquiries.

私有构造函数是私有的,因为该类是由一个对象关系映射(ORM)库从数据库中恢复的。该ORM不需要公共构造函数;它很可能用一个私有的构造函数来工作。同时,我们的系统也不需要一个构造函数,因为它不负责创建这些查询。

How do you test the Inquiry class given that you can’t instantiate its objects? On the one hand, the approval logic is clearly important and thus should be unit tested. But on the other, making the constructor public would violate the rule of not exposing private methods.

鉴于你不能实例化它的对象,你如何测试查询类?一方面,审批逻辑显然很重要,因此应该进行单元测试。但另一方面,将构造函数公开会违反不公开私有方法的规则。

Inquiry’s constructor is an example of a method that is both private and part of the observable behavior. This constructor fulfills the contract with the ORM, and the fact that it’s private doesn’t make that contract less important: the ORM wouldn’t be able to restore inquiries from the database without it.

Inquiry的构造函数是一个既是私有方法又是可观察行为的一部分的例子。这个构造函数履行了与ORM的契约,而且它是私有的这一事实并没有使契约变得不那么重要:没有它ORM就无法从数据库中恢复查询。

And so, making Inquiry’s constructor public won’t lead to test brittleness in this particular case. In fact, it will arguably bring the class’s API closer to being well-designed. Just make sure the constructor contains all the preconditions required to maintain its encapsulation. In listing 11.3, such a precondition is the requirement to have the approval time in all approved inquiries.

因此,在这种特殊情况下,将查询的构造函数公开并不会导致测试的脆性。事实上,它可以说使该类的API更接近于设计良好。只要确保构造函数包含维护其封装性所需的所有前提条件。在列表11.3中,这样的前提条件是要求在所有批准的查询中拥有批准时间。

Alternatively, if you prefer to keep the class’s public API surface as small as possible, you can instantiate Inquiry via reflection in tests. Although this looks like a hack, you are just following the ORM, which also uses reflection behind the scenes.

另外,如果你喜欢保持该类的公共API表面尽可能小,你可以在测试中通过反射来实例化查询。虽然这看起来像一个黑客,但你只是在遵循ORM,它也在幕后使用反射。

11.2 Exposing private state

Another common anti-pattern is exposing private state for the sole purpose of unit testing. The guideline here is the same as with private methods: don’t expose state that you would otherwise keep private—test observable behavior only. Let’s take a look at the following listing.

另一个常见的反模式是仅仅为了单元测试而暴露私有状态。这里的指导原则与私有方法相同:不要暴露你本来要保留的状态—只测试可观察的行为。让我们看一下下面的列表。

Listing 11.4 A class with private state

public class Customer
{
    private CustomerStatus _status =
        CustomerStatus.Regular;        ❶

    public void Promote()
    {
        _status = CustomerStatus.Preferred;
    }

    public decimal GetDiscount()
    {
        return _status == CustomerStatus.Preferred ? 0.05m : 0m;
    }
}

public enum CustomerStatus
{
    Regular,
    Preferred
}

This example shows a Customer class. Each customer is created in the Regular status and then can be promoted to Preferred, at which point they get a 5% discount on everything.

这个例子显示了一个客户类。每个客户都是以普通状态创建的,然后可以晋升为首选状态,这时他们可以获得5%的折扣。

How would you test the Promote() method? This method’s side effect is a change of the _status field, but the field itself is private and thus not available in tests. A tempting solution would be to make this field public. After all, isn’t the change of status the ultimate goal of calling Promote()?

你将如何测试Promote()方法?这个方法的副作用是改变_status字段,但这个字段本身是私有的,因此不能在测试中使用。一个诱人的解决方案是将这个字段公开。毕竟,状态的改变不是调用Promote()的最终目的吗?

That would be an anti-pattern, however. Remember, your tests should interact with the system under test (SUT) exactly the same way as the production code and shouldn’t have any special privileges. In listing 11.4, the _status field is hidden from the production code and thus is not part of the SUT’s observable behavior. Exposing that field would result in coupling tests to implementation details. How to test Promote(), then? _

然而,这将是一个反模式的做法。记住,你的测试应该与被测系统(SUT)的交互方式与生产代码完全相同,不应该有任何特殊的权限。在列表11.4中,_status字段从生产代码中隐藏起来,因此不是SUT的可观察行为的一部分。暴露这个字段会导致测试与实现细节的耦合。那么,如何测试Promote()呢?

What you should do, instead, is look at how the production code uses this class. In this particular example, the production code doesn’t care about the customer’s status; otherwise, that field would be public. The only information the production code does care about is the discount the customer gets after the promotion. And so that’s what you need to verify in tests. You need to check that

你应该做的,是看生产代码是如何使用这个类的。在这个特殊的例子中,生产代码并不关心客户的状态;否则,这个字段将是公开的。生产代码所关心的唯一信息是客户在促销后得到的折扣。因此,这就是你需要在测试中验证的内容。你需要检查

  • A newly created customer has no discount. 一个新创建的客户没有折扣。
  • Once the customer is promoted, the discount becomes 5%. 一旦客户被提升,折扣就变成了5%。

Later, if the production code starts using the customer status field, you’d be able to couple to that field in tests too, because it would officially become part of the SUT’s observable behavior.

之后,如果生产代码开始使用客户状态字段,你也可以在测试中耦合到该字段,因为它将正式成为SUT可观察行为的一部分。

NOTE

Widening the public API surface for the sake of testability is a bad practice.

为了可测试性而扩大公共API的表面是一种不好的做法。

11.3 Leaking domain knowledge to test

Leaking domain knowledge to tests is another quite common anti-pattern. It usually takes place in tests that cover complex algorithms. Let’s take the following (admittedly, not that complex) calculation algorithm as an example:

将领域知识泄露给测试是另一种相当普遍的反模式。它通常发生在涵盖复杂算法的测试中。让我们以下面这个(公认的,没有那么复杂)计算算法为例:

public static class Calculator
{
    public static int Add(int value1, int value2)
    {
        return value1 + value2;
    }
}

This listing shows an incorrect way to test it.

这份清单显示了一种不正确的测试方法。

Listing 11.5 Leaking algorithm implementation

public class CalculatorTests
{
    [Fact]
    public void Adding_two_numbers()
    {
        int value1 = 1;
        int value2 = 3;
        int expected = value1 + value2;   ❶

        int actual = Calculator.Add(value1, value2);

        Assert.Equal(expected, actual);
    }
}

You could also parameterize the test to throw in a couple more test cases at almost no additional cost.

你也可以对测试进行参数化,在几乎没有任何额外成本的情况下,再抛出几个测试案例。

Listing 11.6 A parameterized version of the same test

public class CalculatorTests
{
    [Theory]
    [InlineData(1, 3)]
    [InlineData(11, 33)]
    [InlineData(100, 500)]
    public void Adding_two_numbers(int value1, int value2)
    {
        int expected = value1 + value2;   ❶

        int actual = Calculator.Add(value1, value2);

        Assert.Equal(expected, actual);
    }
}

Listings 11.5 and 11.6 look fine at first, but they are, in fact, examples of the anti-pattern: these tests duplicate the algorithm implementation from the production code. Of course, it might not seem like a big deal. After all, it’s just one line. But that’s only because the example is rather simplified. I’ve seen tests that covered complex algorithms and did nothing but reimplement those algorithms in the arrange part. They were basically a copy-paste from the production code.

列表11.5和11.6乍看起来很好,但事实上,它们是反模式的例子:这些测试重复了生产代码中的算法实现。当然,这可能看起来不是什么大问题。毕竟,这只是一行而已。但这只是因为这个例子是相当简化的。我见过涉及复杂算法的测试,除了在安排部分重新实现这些算法外,什么也没做。他们基本上是从生产代码中复制粘贴的。

These tests are another example of coupling to implementation details. They score almost zero on the metric of resistance to refactoring and are worthless as a result. Such tests don’t have a chance of differentiating legitimate failures from false positives. Should a change in the algorithm make those tests fail, the team would most likely just copy the new version of that algorithm to the test without even trying to identify the root cause (which is understandable, because the tests were a mere duplication of the algorithm in the first place).

这些测试是另一个实现细节耦合的例子。它们在抵抗重构方面的得分几乎为零,因此毫无价值。这样的测试没有机会区分合法的失败和假阳性。如果算法的改变使这些测试失败,团队很可能只是将该算法的新版本复制到测试中,甚至没有试图找出根本原因(这是可以理解的,因为这些测试首先只是算法的复制)。

How to test the algorithm properly, then? Don’t imply any specific implementation when writing tests. Instead of duplicating the algorithm, hard-code its results into the test, as shown in the following listing.

那么,如何正确地测试算法呢?在编写测试时不要暗示任何具体的实现。不要复制算法,而是将其结果硬编码到测试中,如下面的列表所示。

Listing 11.7 Test with no domain knowledge

public class CalculatorTests
{
    [Theory]
    [InlineData(1, 3, 4)]
    [InlineData(11, 33, 44)]
    [InlineData(100, 500, 600)]
    public void Adding_two_numbers(int value1, int value2, int expected)
    {
        int actual = Calculator.Add(value1, value2);
        Assert.Equal(expected, actual);
    }
}

It can seem counterintuitive at first, but hardcoding the expected result is a good practice when it comes to unit testing. The important part with the hardcoded values is to precalculate them using something other than the SUT, ideally with the help of a domain expert. Of course, that’s only if the algorithm is complex enough (we are all experts at summing up two numbers). Alternatively, if you refactor a legacy application, you can have the legacy code produce those results and then use them as expected values in tests.

起初,这似乎是违反直觉的,但当涉及到单元测试时,硬编码的预期结果是一个好的做法。硬编码值的重要部分是使用SUT以外的东西来预先计算它们,最好是在领域专家的帮助下。当然,这只是在算法足够复杂的情况下(我们都是对两个数字进行求和的专家)。另外,如果你重构一个遗留的应用程序,你可以让遗留的代码产生这些结果,然后在测试中使用它们作为预期值。

11.4 Code pollution

The next anti-pattern is code pollution.

下一个反模式是代码污染。

DEFINITION

Code pollution is adding production code that’s only needed for testing.

代码污染是指添加仅用于测试的生产代码。

Code pollution often takes the form of various types of switches. Let’s take a logger as an example.

代码污染通常以各种类型的开关的形式出现。让我们以一个记录器为例。

Listing 11.8 Logger with a Boolean switch

public class Logger
{
    private readonly bool _isTestEnvironment;

    public Logger(bool isTestEnvironment)   ❶
    {
        _isTestEnvironment = isTestEnvironment;
    }

    public void Log(string text)
    {
        if (_isTestEnvironment)   ❶
            return;

        /* Log the text */
    }
}

public class Controller
{
    public void SomeMethod(Logger logger)
    {
        logger.Log("SomeMethod is called");
    }
}

In this example, Logger has a constructor parameter that indicates whether the class runs in production. If so, the logger records the message into the file; otherwise, it does nothing. With such a Boolean switch, you can disable the logger during test runs, as shown in the following listing.

在这个例子中,Logger有一个构造参数,表示该类是否在生产中运行。如果是,日志器会将消息记录到文件中;否则,它什么也不做。有了这样一个布尔开关,你可以在测试运行期间禁用日志器,如下面的列表所示。

Listing 11.9 A test using the Boolean switch

[Fact]
public void Some_test()
{
    var logger = new Logger(true);   ❶
    var sut = new Controller();

    sut.SomeMethod(logger);

    /* assert */
}

The problem with code pollution is that it mixes up test and production code and thereby increases the maintenance costs of the latter. To avoid this anti-pattern, keep the test code out of the production code base.

代码污染的问题是,它把测试和生产代码混在一起,从而增加了后者的维护成本。为了避免这种反模式,将测试代码放在生产代码库之外。

In the example with Logger, introduce an ILogger interface and create two implementations of it: a real one for production and a fake one for testing purposes. After that, re-target Controller to accept the interface instead of the concrete class, as shown in the following listing.

在使用Logger的例子中,引入一个ILogger接口,并创建两个实现:一个是用于生产的真实接口,一个是用于测试的假接口。之后,重新定位Controller以接受该接口而不是具体的类,如下面的列表所示。

Listing 11.10 A version without the switch

public interface ILogger
{
    void Log(string text);
}

public class Logger : ILogger       ❶
{                                   ❶
    public void Log(string text)    ❶
    {                               ❶
        /* Log the text */
    }                               ❶
}                                   ❶

public class FakeLogger : ILogger   ❷
{                                   ❷
    public void Log(string text)    ❷
    {                               ❷
        /* Do nothing */
    }                               ❷
}                                   ❷

public class Controller
{
    public void SomeMethod(ILogger logger)
    {
        logger.Log("SomeMethod is called");
    }
}

Such a separation helps keep the production logger simple because it no longer has to account for different environments. Note that ILogger itself is arguably a form of code pollution: it resides in the production code base but is only needed for testing. So how is the new implementation better?

这样的分离有助于保持生产记录器的简单,因为它不再需要考虑不同的环境。请注意,ILogger本身可以说是一种代码污染:它存在于生产代码库中,但只在测试时需要。那么新的实现如何更好呢?

The kind of pollution ILogger introduces is less damaging and easier to deal with. Unlike the initial Logger implementation, with the new version, you can’t accidentally invoke a code path that isn’t intended for production use. You can’t have bugs in interfaces, either, because they are just contracts with no code in them. In contrast to Boolean switches, interfaces don’t introduce additional surface area for potential bugs.

Logger引入的那种污染的破坏性更小,更容易处理。与最初的ILogger实现不同,在新版本中,你不能意外地调用一个不打算用于生产的代码路径。你也不可能在接口中出现bug,因为它们只是没有代码的契约。与布尔开关相比,接口不会为潜在的错误引入额外的表面积。

11.5 Mocking concrete classes

So far, this book has shown mocking examples using interfaces, but there’s an alternative approach: you can mock concrete classes instead and thus preserve part of the original classes’ functionality, which can be useful at times. This alternative has a significant drawback, though: it violates the Single Responsibility principle. The next listing illustrates this idea.

到目前为止,本书已经展示了使用接口进行嘲讽的例子,但还有另一种方法:你可以用嘲讽具体类来代替,从而保留原类的部分功能,这在某些时候很有用。不过这种替代方法有一个很大的缺点:它违反了单一责任原则。下面的列表说明了这个想法。

Listing 11.11 A class that calculates statistics

public class StatisticsCalculator
{
    public (double totalWeight, double totalCost) Calculate(
        int customerId)
    {
        List<DeliveryRecord> records = GetDeliveries(customerId);

        double totalWeight = records.Sum(x => x.Weight);
        double totalCost = records.Sum(x => x.Cost);

        return (totalWeight, totalCost);
    }

    public List<DeliveryRecord> GetDeliveries(int customerId)
    {
        /* Call an out-of-process dependency
        to get the list of deliveries */
    }
}

StatisticsCalculator gathers and calculates customer statistics: the weight and cost of all deliveries sent to a particular customer. The class does the calculation based on the list of deliveries retrieved from an external service (the GetDeliveries method). Let’s also say there’s a controller that uses StatisticsCalculator, as shown in the following listing.

StatisticsCalculator收集并计算客户的统计数据:发给某个特定客户的所有货物的重量和成本。该类根据从外部服务(GetDeliveries方法)中获取的交付物列表进行计算。我们还假设有一个使用StatisticsCalculator的控制器,如下面列表所示。

Listing 11.12 A controller using StatisticsCalculator

public class CustomerController
{
    private readonly StatisticsCalculator _calculator;

    public CustomerController(StatisticsCalculator calculator)
    {
        _calculator = calculator;
    }

    public string GetStatistics(int customerId)
    {
        (double totalWeight, double totalCost) = _calculator
            .Calculate(customerId);

        return
            $"Total weight delivered: {totalWeight}. " +
            $"Total cost: {totalCost}";
    }
}

How would you test this controller? You can’t supply it with a real StatisticsCalculator instance, because that instance refers to an unmanaged out-of-process dependency. The unmanaged dependency has to be substituted with a stub. At the same time, you don’t want to replace StatisticsCalculator entirely, either. This class contains important calculation functionality, which needs to be left intact.

你如何测试这个控制器?你不能给它提供一个真正的StatisticsCalculator实例,因为这个实例是指一个未被管理的进程外依赖。未被管理的依赖必须被替换成一个存根。同时,你也不想完全取代StatisticsCalculator。这个类包含了重要的计算功能,需要保持原样。

One way to overcome this dilemma is to mock the StatisticsCalculator class and override only the GetDeliveries() method, which can be done by making that method virtual, as shown in the following listing.

克服这一困境的方法是模拟StatisticsCalculator类,只覆盖GetDeliveries()方法,这可以通过使该方法成为虚函数来实现,如下面的列表所示。

Listing 11.13 Test that mocks the concrete class

[Fact]
public void Customer_with_no_deliveries()
{
    // Arrange
    var stub = new Mock<StatisticsCalculator> { CallBase = true };
    stub.Setup(x => x.GetDeliveries(1))   ❶
        .Returns(new List<DeliveryRecord>());
    var sut = new CustomerController(stub.Object);

    // Act
    string result = sut.GetStatistics(1);

    // Assert
    Assert.Equal("Total weight delivered: 0. Total cost: 0", result);
}

The CallBase = true setting tells the mock to preserve the base class’s behavior unless it’s explicitly overridden. With this approach, you can substitute only a part of the class while keeping the rest as-is. As I mentioned earlier, this is an anti-pattern.

CallBase = true设置告诉mock保留基类的行为,除非它被明确重写。通过这种方法,你可以只替换类的一部分,而保持其他部分的原样。正如我前面提到的,这是一种反模式。

NOTE

The necessity to mock a concrete class in order to preserve part of its functionality is a result of violating the Single Responsibility principle.

为了保留一个具体的类的部分功能而必须对其进行模拟,这是违反了单一责任原则的结果。

StatisticsCalculator combines two unrelated responsibilities: communicating with the unmanaged dependency and calculating statistics. Look at listing 11.11 again. The Calculate() method is where the domain logic lies. GetDeliveries() just gathers the inputs for that logic. Instead of mocking StatisticsCalculator, split this class in two, as the following listing shows.

StatisticsCalculator结合了两个不相关的责任:与非管理的依赖关系进行通信和计算统计数据。再看一下清单11.11。Calculate()方法是领域逻辑的所在。GetDeliveries()只是为该逻辑收集输入。与其模拟StatisticsCalculator,不如将这个类一分为二,如下面的列表所示。

Listing 11.14 Splitting StatisticsCalculator into two classes

public class DeliveryGateway : IDeliveryGateway
{
    public List<DeliveryRecord> GetDeliveries(int customerId)
    {
        /* Call an out-of-process dependency
        to get the list of deliveries */
    }
}

public class StatisticsCalculator
{
    public (double totalWeight, double totalCost) Calculate(
        List<DeliveryRecord> records)
    {
        double totalWeight = records.Sum(x => x.Weight);
        double totalCost = records.Sum(x => x.Cost);

        return (totalWeight, totalCost);
    }
}

The next listing shows the controller after the refactoring.

下一个列表显示了重构后的控制器。

Listing 11.15 Controller after the refactoring

public class CustomerController
{
    private readonly StatisticsCalculator _calculator;
    private readonly IDeliveryGateway _gateway;

    public CustomerController(
        StatisticsCalculator calculator,   ❶
        IDeliveryGateway gateway)          ❶
    {
        _calculator = calculator;
        _gateway = gateway;
    }

    public string GetStatistics(int customerId)
    {
        var records = _gateway.GetDeliveries(customerId);
        (double totalWeight, double totalCost) = _calculator
            .Calculate(records);

        return
            $"Total weight delivered: {totalWeight}. " +
            $"Total cost: {totalCost}";
    }
}

The responsibility of communicating with the unmanaged dependency has transitioned to DeliveryGateway. Notice how this gateway is backed by an interface, which you can now use for mocking instead of the concrete class. The code in listing 11.15 is an example of the Humble Object design pattern in action. Refer to chapter 7 to learn more about this pattern.

与非管理的依赖关系进行通信的责任已经过渡到DeliveryGateway。注意这个网关是如何被一个接口支持的,你现在可以用它来模拟,而不是用具体的类。列表11.15中的代码是谦卑对象设计模式的一个实例。请参考第7章,了解更多关于这个模式的信息。

11.6 Working with time

Many application features require access to the current date and time. Testing functionality that depends on time can result in false positives, though: the time during the act phase might not be the same as in the assert. There are three options for stabilizing this dependency. One of these options is an anti-pattern; and of the other two, one is preferable to the other.

许多应用功能需要访问当前的日期和时间。不过,测试依赖于时间的功能可能会导致假阳性:行为阶段的时间可能与断言阶段的时间不一致。有三个选项可以稳定这种依赖关系。其中一个是反模式;另外两个中,一个比另一个更好。

11.6.1 Time as an ambient context

The first option is to use the ambient context pattern. You already saw this pattern in chapter 8 in the section about testing loggers. In the context of time, the ambient context would be a custom class that you’d use in code instead of the framework’s built-in DateTime.Now, as shown in the next listing.

第一个选择是使用环境上下文模式。你已经在第8章关于测试记录器的章节中看到了这种模式。在时间的上下文中,环境上下文将是一个自定义的类,你可以在代码中使用,而不是框架内置的DateTime.Now,如下面的列表所示。

Listing 11.16 Current date and time as an ambient context

public static class DateTimeServer
{
    private static Func<DateTime> _func;
    public static DateTime Now => _func();

    public static void Init(Func<DateTime> func)
    {
        _func = func;
    }
}

DateTimeServer.Init(() => DateTime.Now);   ❶

DateTimeServer.Init(() => new DateTime(2020, 1, 1));  ❷

Just as with the logger functionality, using an ambient context for time is also an antipattern. The ambient context pollutes the production code and makes testing more difficult. Also, the static field introduces a dependency shared between tests, thus transitioning those tests into the sphere of integration testing.

就像记录器的功能一样,为时间使用环境上下文也是一种反模式的做法。环境上下文污染了生产代码,使测试更加困难。另外,静态字段引入了测试之间共享的依赖关系,从而使这些测试过渡到集成测试的领域。

11.6.2 Time as an explicit dependency

A better approach is to inject the time dependency explicitly (instead of referring to it via a static method in an ambient context), either as a service or as a plain value, as shown in the following listing.

一个更好的方法是显式地注入时间依赖(而不是通过环境上下文中的静态方法来引用它),或者作为一个服务,或者作为一个普通的值,如下面的列表所示。

Listing 11.17 Current date and time as an explicit dependency

public interface IDateTimeServer
{
    DateTime Now { get; }
}

public class DateTimeServer : IDateTimeServer
{
    public DateTime Now => DateTime.Now;
}

public class InquiryController
{
    private readonly DateTimeServer _dateTimeServer;

    public InquiryController(
        DateTimeServer dateTimeServer)   ❶
    {
        _dateTimeServer = dateTimeServer;
    }

    public void ApproveInquiry(int id)
    {
        Inquiry inquiry = GetById(id);
        inquiry.Approve(_dateTimeServer.Now);   ❷
        SaveInquiry(inquiry);
    }
}

Of these two options, prefer injecting the time as a value rather than as a service. It’s easier to work with plain values in production code, and it’s also easier to stub those values in tests.

在这两个选项中,更倾向于将时间作为一个值注入,而不是作为一个服务。在生产代码中使用普通值更容易,而且在测试中存根这些值也更容易。

Most likely, you won’t be able to always inject the time as a plain value, because dependency injection frameworks don’t play well with value objects. A good compromise is to inject the time as a service at the start of a business operation and then pass it as a value in the remainder of that operation. You can see this approach in listing 11.17: the controller accepts DateTimeServer (the service) but then passes a DateTime value to the Inquiry domain class.

最有可能的是,你不能总是把时间作为一个普通的值来注入,因为依赖注入框架并不能很好地处理值对象。一个好的折衷办法是在业务操作开始时将时间作为一个服务注入,然后在该操作的剩余部分将其作为一个值传递。你可以在清单11.17中看到这种方法:控制器接受DateTimeServer(服务),但随后将一个DateTime值传递给查询域类。

11.7 Conclusion

In this chapter, we looked at some of the most prominent real-world unit testing use cases and analyzed them using the four attributes of a good test. I understand that it may be overwhelming to start applying all the ideas and guidelines from this book at once. Also, your situation might not be as clear-cut. I publish reviews of other people’s code and answer questions (related to unit testing and code design in general) on my blog at https://enterprisecraftsmanship.com. You can also submit your own question at https://enterprisecraftsmanship.com/about. You might also be interested in taking my online course, where I show how to build an application from the ground up, applying all the principles described in this book in practice, at https://unittestingcourse.com.

在这一章中,我们看了一些现实世界中最突出的单元测试用例,并用一个好的测试的四个属性来分析它们。我理解,一下子开始应用本书的所有想法和准则可能会让人不知所措。另外,你的情况可能没有那么明确。我在我的博客 https://enterprisecraftsmanship.com 上发布对其他人的代码的评论,并回答问题(与单元测试和一般的代码设计有关)。你也可以在https://enterprisecraftsmanship.com/about,提交你自己的问题。你可能还有兴趣参加我的在线课程,在那里我展示了如何从头开始构建一个应用程序,在实践中应用本书中描述的所有原则,网址是https://unittestingcourse.com。

You can always catch me on twitter at @vkhorikov, or contact me directly through https://enterprisecraftsmanship.com/about. I look forward to hearing from you!

你可以随时在twitter上找到我:@vkhorikov,或者通过https://enterprisecraftsmanship.com/about 直接联系我。我期待着你的来信!

Summary

  • Exposing private methods to enable unit testing leads to coupling tests to implementation and, ultimately, damaging the tests’ resistance to refactoring. Instead of testing private methods directly, test them indirectly as part of the overarching observable behavior.

  • If the private method is too complex to be tested as part of the public API that uses it, that’s an indication of a missing abstraction. Extract this abstraction into a separate class instead of making the private method public.

  • In rare cases, private methods do belong to the class’s observable behavior. Such methods usually implement a non-public contract between the class and an ORM or a factory.

  • Don’t expose state that you would otherwise keep private for the sole purpose of unit testing. Your tests should interact with the system under test exactly the same way as the production code; they shouldn’t have any special privileges.

  • Don’t imply any specific implementation when writing tests. Verify the production code from a black-box perspective; avoid leaking domain knowledge to tests (see chapter 4 for more details about black-box and white-box testing).

  • Code pollution is adding production code that’s only needed for testing. It’s an anti-pattern because it mixes up test and production code and increases the maintenance costs of the latter.

  • The necessity to mock a concrete class in order to preserve part of its functionality is a result of violating the Single Responsibility principle. Separate that class into two classes: one with the domain logic, and the other one communicating with the out-of-process dependency.

  • Representing the current time as an ambient context pollutes the production code and makes testing more difficult. Inject time as an explicit dependency— either as a service or as a plain value. Prefer the plain value whenever possible.