.NET Core 和 .NET Standard 单元测试最佳做法Unit testing best practices with .NET Core and .NET Standard

编写单元测试有许多好处;它们有助于回归、提供文档和促进良好的设计。There are numerous benefits to writing unit tests; they help with regression, provide documentation, and facilitate good design. 然而,难懂且脆弱的单元测试会对代码库造成严重破坏。However, hard to read and brittle unit tests can wreak havoc on your code base. 本文介绍一些有关 .NET Core 和 .NET Standard 项目的单元测试设计的最佳做法。This article describes some best practices regarding unit test design for your .NET Core and .NET Standard projects.

本指南将介绍一些在编写单元测试时的最佳做法,使测试具有弹性且易于理解。In this guide, you'll learn some best practices when writing unit tests to keep your tests resilient and easy to understand.

作者是 John Reese 且特别感谢 Roy OsheroveBy John Reese with special thanks to Roy Osherove

为什么要执行单元测试?Why unit test?

比执行功能测试节省时间Less time performing functional tests

功能测试费用高。Functional tests are expensive. 它们通常涉及打开应用程序并执行一系列你(或其他人)必须遵循的步骤,以验证预期的行为。They typically involve opening up the application and performing a series of steps that you (or someone else), must follow in order to validate the expected behavior. 测试人员可能并不总是了解这些步骤,这意味着为了执行测试,他们必须联系更熟悉该领域的人。These steps may not always be known to the tester, which means they will have to reach out to someone more knowledgeable in the area in order to carry out the test. 对于细微更改,测试本身可能需要几秒钟,对于较大更改,可能需要几分钟。Testing itself could take seconds for trivial changes, or minutes for larger changes. 最后,在系统中所做的每项更改都必须重复此过程。Lastly, this process must be repeated for every change that you make in the system.

而单元测试只需按一下按钮即可运行,只需要几毫秒时间,且无需测试人员了解整个系统。Unit tests, on the other hand, take milliseconds, can be run at the press of a button, and don't necessarily require any knowledge of the system at large. 测试通过与否取决于测试运行程序,而非测试人员。Whether or not the test passes or fails is up to the test runner, not the individual.

防止回归Protection against regression

回归缺陷是在对应用程序进行更改时引入的缺陷。Regression defects are defects that are introduced when a change is made to the application. 测试人员通常不仅测试新功能,还要测试预先存在的功能,以验证先前实现的功能是否仍按预期运行。It is common for testers to not only test their new feature but also features that existed beforehand in order to verify that previously implemented features still function as expected.

使用单元测试,可在每次生成后,甚至在更改一行代码后重新运行整套测试。With unit testing, it's possible to rerun your entire suite of tests after every build or even after you change a line of code. 让你确信新代码不会破坏现有功能。Giving you confidence that your new code does not break existing functionality.

可执行文档Executable documentation

在给定某个输入的情况下,特定方法的作用或行为可能并不总是很明显。It may not always be obvious what a particular method does or how it behaves given a certain input. 你可能会想知道:如果我将空白字符串传递给它,此方法会有怎样的行为?You may ask yourself: How does this method behave if I pass it a blank string? Null?Null?

如果你有一套命名正确的单元测试,每个测试应能够清楚地解释给定输入的预期输出。When you have a suite of well-named unit tests, each test should be able to clearly explain the expected output for a given input. 此外,它应该能够验证其确实有效。In addition, it should be able to verify that it actually works.

减少耦合代码Less coupled code

当代码紧密耦合时,可能难以进行单元测试。When code is tightly coupled, it can be difficult to unit test. 如果不为编写的代码创建单元测试,则耦合可能不太明显。Without creating unit tests for the code that you're writing, coupling may be less apparent.

为代码编写测试会自然地解耦代码,因为采用其他方法测试会更困难。Writing tests for your code will naturally decouple your code, because it would be more difficult to test otherwise.

优质单元测试的特征Characteristics of a good unit test

  • 快速。Fast. 对成熟项目进行数千次单元测试,这很常见。It is not uncommon for mature projects to have thousands of unit tests. 应花非常少的时间来运行单元测试。Unit tests should take very little time to run. 几毫秒。Milliseconds.
  • 独立。Isolated. 单元测试是独立的,可以单独运行,并且不依赖文件系统或数据库等任何外部因素。Unit tests are standalone, can be run in isolation, and have no dependencies on any outside factors such as a file system or database.
  • 可重复。Repeatable. 运行单元测试的结果应该保持一致,也就是说,如果在运行期间不更改任何内容,总是返回相同的结果。Running a unit test should be consistent with its results, that is, it always returns the same result if you do not change anything in between runs.
  • 自检查。Self-Checking. 测试应该能够在没有任何人工交互的情况下,自动检测测试是否通过。The test should be able to automatically detect if it passed or failed without any human interaction.
  • 及时。Timely. 与要测试的代码相比,编写单元测试不应花费过多不必要的时间。A unit test should not take a disproportionately long time to write compared to the code being tested. 如果发现测试代码与编写代码相比需要花费大量的时间,请考虑一种更易测试的设计。If you find testing the code taking a large amount of time compared to writing the code, consider a design that is more testable.

代码覆盖率Code coverage

高代码覆盖率百分比通常与较高的代码质量相关联。A high code coverage percentage is often associated with a higher quality of code. 但该度量值本身无法确定代码的质量。However, the measurement itself cannot determine the quality of code. 设置过高的代码覆盖率百分比目标可能会适得其反。Setting an overly ambitious code coverage percentage goal can be counterproductive. 假设一个复杂的项目有数千个条件分支,并且假设你设定了一个 95% 代码覆盖率的目标。Imagine a complex project with thousands of conditional branches, and imagine that you set a goal of 95% code coverage. 该项目当前维保持 90% 的代码覆盖率。Currently the project maintains 90% code coverage. 要覆盖剩余 5% 的所有边缘事例,需要花费巨大的工作量,而且价值主张会迅速降低。The amount of time it takes to account for all of the edge cases in the remaining 5% could be a massive undertaking, and the value proposition quickly diminishes.

高代码覆盖率百分比不指示成功,也不意味着高代码质量。A high code coverage percentage is not an indicator of success, nor does it imply high code quality. 它仅仅表示单元测试所涵盖的代码量。It just represents the amount of code that is covered by unit tests. 有关详细信息,请参阅单元测试代码覆盖率For more information, see unit testing code coverage.

让我们使用相同的术语Let's speak the same language

遗憾的是,当谈到测试时,术语“mock”经常被滥用。The term mock is unfortunately often misused when talking about testing. 以下几点定义了编写单元测试时最常见的 fake 类型:The following points define the most common types of fakes when writing unit tests:

Fake - Fake 是一个通用术语,可用于描述 stub 或 mock 对象。Fake - A fake is a generic term that can be used to describe either a stub or a mock object. 它是 stub 还是 mock 取决于使用它的上下文。Whether it's a stub or a mock depends on the context in which it's used. 也就是说,Fake 可以是 stub 或 mock。So in other words, a fake can be a stub or a mock.

Mock - Mock 对象是系统中的 fake 对象,用于确定单元测试是否通过。Mock - A mock object is a fake object in the system that decides whether or not a unit test has passed or failed. Mock 起初为 Fake,直到对其断言。A mock starts out as a Fake until it's asserted against.

Stub - Stub 是系统中现有依赖项(或协作者)的可控制替代项。Stub - A stub is a controllable replacement for an existing dependency (or collaborator) in the system. 通过使用 Stub,可以在无需使用依赖项的情况下直接测试代码。By using a stub, you can test your code without dealing with the dependency directly. 默认情况下,fake 起初为 Stub。By default, a fake starts out as a stub.

请思考以下代码片段:Consider the following code snippet:

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

这是 Stub 被引用为 mock 的一个示例。This would be an example of stub being referred to as a mock. 在本例中,它是 Stub。In this case, it is a stub. 只是将 Order 作为实例化 Purchase(被测系统)的一种方法传递。You're just passing in the Order as a means to be able to instantiate Purchase (the system under test). 名称 MockOrder 也具有误导性,因为同样地 order 不是 mock。The name MockOrder is also misleading because again, the order is not a mock.

更好的方法是A better approach would be

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

通过将类重命名为 FakeOrder,使类更通用,类可以用作 mock 或 stub。By renaming the class to FakeOrder, you've made the class a lot more generic, the class can be used as a mock or a stub. 以更适合测试用例者为准。Whichever is better for the test case. 在上述示例中,FakeOrder 用作 stub。In the above example, FakeOrder is used as a stub. 在断言期间,没有以任何形状或形式使用 FakeOrderYou're not using the FakeOrder in any shape or form during the assert. FakeOrder 传递到 Purchase 类,以满足构造函数的要求。FakeOrder was passed into the Purchase class to satisfy the requirements of the constructor.

要将其用作 Mock,可执行如下操作To use it as a Mock, you could do something like this

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

在这种情况下,检查 Fake 上的属性(对其进行断言),因此在以上代码片段中,mockOrder 是 Mock。In this case, you are checking a property on the Fake (asserting against it), so in the above code snippet, the mockOrder is a Mock.

重要

正确理解此术语至关重要。It's important to get this terminology correct. 如果将 stub 称为 mock,其他开发人员会对你的意图做出错误的判断。If you call your stubs "mocks", other developers are going to make false assumptions about your intent.

关于 mock 与 stub 需要注意的一个重点是,mock 与 stub 很像,但可以针对 mock 对象进行断言,而不针对 stub 进行断言。The main thing to remember about mocks versus stubs is that mocks are just like stubs, but you assert against the mock object, whereas you do not assert against a stub.

最佳实践Best practices

为测试命名Naming your tests

测试的名称应包括三个部分:The name of your test should consist of three parts:

  • 要测试的方法的名称。The name of the method being tested.
  • 测试的方案。The scenario under which it's being tested.
  • 调用方案时的预期行为。The expected behavior when the scenario is invoked.

为什么?Why?

  • 命名标准非常重要,因为它们明确地表达了测试的意图。Naming standards are important because they explicitly express the intent of the test.

测试不仅能确保代码有效,还能提供文档。Tests are more than just making sure your code works, they also provide documentation. 只需查看单元测试套件,就可以在不查看代码本身的情况下推断代码的行为。Just by looking at the suite of unit tests, you should be able to infer the behavior of your code without even looking at the code itself. 此外,测试失败时,可以确切地看到哪些方案不符合预期。Additionally, when tests fail, you can see exactly which scenarios do not meet your expectations.

不佳:Bad:

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

良好:Better:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

安排测试Arranging your tests

“Arrange、Act、Assert”是单元测试时的常见模式。Arrange, Act, Assert is a common pattern when unit testing. 顾名思义,它包含三个主要操作:As the name implies, it consists of three main actions:

  • 安排对象,根据需要对其进行创建和设置。Arrange your objects, creating and setting them up as necessary.
  • 作用于对象。Act on an object.
  • 断言某些项按预期进行。Assert that something is as expected.

为什么?Why?

  • 明确地将要测试的内容从“arrange”和“assert”步骤分开 。Clearly separates what is being tested from the arrange and assert steps.
  • 降低将断言与“Act”代码混杂的可能性。Less chance to intermix assertions with "Act" code.

可读性是编写测试时最重要的方面之一。Readability is one of the most important aspects when writing a test. 在测试中分离这些操作会明确地突出显示调用代码所需的依赖项、调用代码的方式以及尝试断言的内容。Separating each of these actions within the test clearly highlight the dependencies required to call your code, how your code is being called, and what you are trying to assert. 虽然可以组合一些步骤并减小测试的大小,但主要目标是使测试尽可能可读。While it may be possible to combine some steps and reduce the size of your test, the primary goal is to make the test as readable as possible.

不佳:Bad:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

良好:Better:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add("");

    // Assert
    Assert.Equal(0, actual);
}

以最精简方式编写通过测试Write minimally passing tests

单元测试中使用的输入应为最简单的输入,以便验证当前正在测试的行为。The input to be used in a unit test should be the simplest possible in order to verify the behavior that you are currently testing.

为什么?Why?

  • 测试对代码库的未来更改更具弹性。Tests become more resilient to future changes in the codebase.
  • 更接近于测试行为而非实现。Closer to testing behavior over implementation.

包含比通过测试所需信息更多信息的测试更可能将错误引入测试,并且可能使测试的意图变得不太明确。Tests that include more information than required to pass the test have a higher chance of introducing errors into the test and can make the intent of the test less clear. 编写测试时需要将重点放在行为上。When writing tests, you want to focus on the behavior. 在模型上设置额外的属性或在不需要时使用非零值,只会偏离所要证明的内容。Setting extra properties on models or using non-zero values when not required, only detracts from what you are trying to prove.

不佳:Bad:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

良好:Better:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

避免魔幻字符串Avoid magic strings

单元测试中的命名变量和生产代码中的命名变量同样重要。Naming variables in unit tests is as important, if not more important, than naming variables in production code. 单元测试不应包含魔幻字符串。Unit tests should not contain magic strings.

为什么?Why?

  • 测试读者无需检查生产代码即可了解值的特殊之处。Prevents the need for the reader of the test to inspect the production code in order to figure out what makes the value special.
  • 明确地显示所要证明的内容,而不是显示要完成的内容 。Explicitly shows what you're trying to prove rather than trying to accomplish.

魔幻字符串可能会让测试读者感到困惑。Magic strings can cause confusion to the reader of your tests. 如果字符串看起来不寻常,他们可能想知道为什么为某个参数或返回值选择了某个值。If a string looks out of the ordinary, they may wonder why a certain value was chosen for a parameter or return value. 这可能会使他们仔细查看实现细节,而不是专注于测试。This may lead them to take a closer look at the implementation details, rather than focus on the test.

提示

编写测试时,应力求表达尽可能多的意图。When writing tests, you should aim to express as much intent as possible. 对于魔幻字符串,一种很好的方法是将这些值赋给常量。In the case of magic strings, a good approach is to assign these values to constants.

不佳:Bad:

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

良好:Better:

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

在测试中应避免逻辑Avoid logic in tests

编写单元测试时,请避免手动字符串串联和逻辑条件,例如 ifwhileforswitch 等等。When writing your unit tests avoid manual string concatenation and logical conditions such as if, while, for, switch, etc.

为什么?Why?

  • 降低在测试中引入 bug 的可能性。Less chance to introduce a bug inside of your tests.
  • 专注于最终结果,而不是实现细节。Focus on the end result, rather than implementation details.

将逻辑引入测试套件中时,引入 bug 的可能性大幅度增加。When you introduce logic into your test suite, the chance of introducing a bug into it increases dramatically. 你最不希望测试套件中出现 bug。The last place that you want to find a bug is within your test suite. 你应该对测试工作充满高度的自信,否则你将不会信任它们。You should have a high level of confidence that your tests work, otherwise, you will not trust them. 你不信任的测试不会带来任何价值。Tests that you do not trust, do not provide any value. 当测试失败时,你希望意识到代码存在问题且无法忽略该问题。When a test fails, you want to have a sense that something is actually wrong with your code and that it cannot be ignored.

提示

如果不可避免地要在测试中使用逻辑,请考虑将测试分成两个或多个不同的测试。If logic in your test seems unavoidable, consider splitting the test up into two or more different tests.

不佳:Bad:

[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
    var stringCalculator = new StringCalculator();
    var expected = 0;
    var testCases = new[]
    {
        "0,0,0",
        "0,1,2",
        "1,2,3"
    };

    foreach (var test in testCases)
    {
        Assert.Equal(expected, stringCalculator.Add(test));
        expected += 3;
    }
}

良好:Better:

[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add(input);

    Assert.Equal(expected, actual);
}

更偏好 helper 方法而非 setup 和 teardownPrefer helper methods to setup and teardown

如果测试需要类似的对象或状态,那么比起使用 Setup 和 Teardown 属性(如果存在),更偏好使用 helper 方法。If you require a similar object or state for your tests, prefer a helper method than leveraging Setup and Teardown attributes if they exist.

为什么?Why?

  • 读者阅读测试时产生的困惑减少,因为每个测试中都可以看到所有代码。Less confusion when reading the tests since all of the code is visible from within each test.
  • 给定测试的设置过多或过少的可能性降低。Less chance of setting up too much or too little for the given test.
  • 在测试之间共享状态(这会在测试之间创建不需要的依赖项)的可能性降低。Less chance of sharing state between tests, which creates unwanted dependencies between them.

在单元测试框架中,在测试套件的每个单元测试之前调用 SetupIn unit testing frameworks, Setup is called before each and every unit test within your test suite. 虽然有些人可能会将其视为有用的工具,但它通常最终导致庞大且难懂的测试。While some may see this as a useful tool, it generally ends up leading to bloated and hard to read tests. 每个测试通常有不同的要求,以使测试启动并运行。Each test will generally have different requirements in order to get the test up and running. 遗憾的是,Setup 迫使你对每个测试使用完全相同的要求。Unfortunately, Setup forces you to use the exact same requirements for each test.

备注

自版本 2.x 起,xUnit 已删除 SetUp 和 TearDownxUnit has removed both SetUp and TearDown as of version 2.x

不佳:Bad:

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
// more tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}

良好:Better:

[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// more tests...
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

避免多个断言Avoid multiple asserts

在编写测试时,请尝试每次测试只包含一个 Assert。When writing your tests, try to only include one Assert per test. 仅使用一个 assert 的常用方法包括:Common approaches to using only one assert include:

  • 为每个 assert 创建单独的测试。Create a separate test for each assert.
  • 使用参数化测试。Use parameterized tests.

为什么?Why?

  • 如果一个 Assert 失败,将不计算后续 Assert。If one Assert fails, the subsequent Asserts will not be evaluated.
  • 确保在测试中没有断言多个事例。Ensures you are not asserting multiple cases in your tests.
  • 让你从整体上了解测试失败原因。Gives you the entire picture as to why your tests are failing.

将多个断言引入测试用例时,不能保证所有断言都会执行。When introducing multiple asserts into a test case, it is not guaranteed that all of the asserts will be executed. 在大多数单元测试框架中,一旦断言在单元测试中失败,则进行中的测试会自动被视为失败。In most unit testing frameworks, once an assertion fails in a unit test, the proceeding tests are automatically considered to be failing. 这可能会令人困惑,因为正在运行的功能将显示为失败。This can be confusing as functionality that is actually working, will be shown as failing.

备注

此规则的一个常见例外是对对象进行断言。A common exception to this rule is when asserting against an object. 在这种情况下,通常可以对每个属性进行多次断言,以确保对象处于所预期的状态。In this case, it is generally acceptable to have multiple asserts against each property to ensure the object is in the state that you expect it to be in.

不佳:Bad:

[Fact]
public void Add_EdgeCases_ThrowsArgumentExceptions()
{
    Assert.Throws<ArgumentException>(() => stringCalculator.Add(null));
    Assert.Throws<ArgumentException>(() => stringCalculator.Add("a"));
}

良好:Better:

[Theory]
[InlineData(null)]
[InlineData("a")]
public void Add_InputNullOrAlphabetic_ThrowsArgumentException(string input)
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add(input);

    Assert.Throws<ArgumentException>(actual);
}

通过单元测试公共方法验证专有方法Validate private methods by unit testing public methods

在大多数情况下,不需要测试专用方法。In most cases, there should not be a need to test a private method. 专用方法是实现细节。Private methods are an implementation detail. 可以这样认为:专用方法永远不会孤立存在。You can think of it this way: private methods never exist in isolation. 在某些时候,存在调用专用方法作为其实现的一部分的面向公共的方法。At some point, there is going to be a public facing method that calls the private method as part of its implementation. 你应关心的是调用到专用方法的公共方法的最终结果。What you should care about is the end result of the public method that calls into the private one.

请考虑下列情形Consider the following case

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

你的第一反应可能是开始为 TrimInput 编写测试,因为想要确保该方法按预期工作。Your first reaction may be to start writing a test for TrimInput because you want to make sure that the method is working as expected. 但是,ParseLogLine 完全有可能以一种你所不期望的方式操纵 sanitizedInput,使得对 TrimInput 的测试变得毫无用处。However, it is entirely possible that ParseLogLine manipulates sanitizedInput in such a way that you do not expect, rendering a test against TrimInput useless.

真正的测试应该针对面向公共的方法 ParseLogLine 进行,因为这是你最终应该关心的。The real test should be done against the public facing method ParseLogLine because that is what you should ultimately care about.

public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

由此,如果看到一个专用方法,可以找到公共方法并针对该方法编写测试。With this viewpoint, if you see a private method, find the public method and write your tests against that method. 不能仅仅因为专用方法返回预期结果就认为最终调用专用方法的系统正确地使用结果。Just because a private method returns the expected result, does not mean the system that eventually calls the private method uses the result correctly.

Stub 静态引用Stub static references

单元测试的原则之一是其必须完全控制被测试的系统。One of the principles of a unit test is that it must have full control of the system under test. 当生产代码包含对静态引用(例如 DateTime.Now)的调用时,这可能会存在问题。This can be problematic when production code includes calls to static references (for example, DateTime.Now). 考虑下列代码Consider the following code

public int GetDiscountedPrice(int price)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

如何对此代码进行单元测试?How can this code possibly be unit tested? 可以尝试一种方法,例如You may try an approach such as

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(2, actual)
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(1, actual);
}

遗憾的是,你会很快意识到你的测试存在一些问题。Unfortunately, you will quickly realize that there are a couple problems with your tests.

  • 如果在星期二运行测试套件,则第二个测试将通过,但第一个测试将失败。If the test suite is run on a Tuesday, the second test will pass, but the first test will fail.
  • 如果在任何其他日期运行测试套件,则第一个测试将通过,但第二个测试将失败。If the test suite is run on any other day, the first test will pass, but the second test will fail.

要解决这些问题,需要将“seam”引入生产代码中。To solve these problems, you'll need to introduce a seam into your production code. 一种方法是在接口中包装需要控制的代码,并使生产代码依赖于该接口。One approach is to wrap the code that you need to control in an interface and have the production code depend on that interface.

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

你的测试套件现在变得Your test suite now becomes

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(2, actual);
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(1, actual);
}

现在,测试套件可以完全控制 DateTime.Now,并且在调用方法时可以存根任何值。Now the test suite has full control over DateTime.Now and can stub any value when calling into the method.