.NET 的单元测试最佳做法

编写单元测试有很多好处。 它们有助于回归、提供文档以及促进良好的设计。 但是,当单元测试难以阅读和脆弱时,它们可能会对代码库造成破坏。 本文介绍设计单元测试以支持 .NET Core 和 .NET Standard 项目的一些最佳做法。 你学习了使测试保持弹性且易于理解的技术。

约翰·里斯 特别感谢 罗伊·奥舍罗夫

单元测试的优点

以下部分介绍了为 .NET Core 和 .NET Standard 项目编写单元测试的几个原因。

执行功能测试的时间更少

功能测试成本高昂。 它们通常涉及打开应用程序并执行一系列步骤,这些步骤需要由您或其他人来完成,以验证应用程序的预期行为。 测试人员可能并不总是知道这些步骤。 他们必须与该领域更有知识的人联系,以便进行测试。 测试本身可能需要几秒钟才能进行简单更改,或者需要几分钟才能进行更大的更改。 最后,对于在系统中所做的每一项更改,都必须重复此过程。 另一方面,单元测试只需耗费毫秒即可运行,只需按下一个按钮,并且不一定需要对整个系统有任何了解。 测试运行程序负责判断测试是否通过或失败,而不是由个人决定。

防止回归

回归缺陷是对应用程序进行更改时引入的错误。 测试人员不仅要测试其新功能,而且测试事先存在的测试功能,以验证现有功能是否仍按预期运行,这很常见。 使用单元测试,可以在每次生成后,甚至在更改代码行之后重新运行整个测试套件。 此方法有助于增强新代码不会破坏现有功能的信心。

可执行文档

特定方法在给定某个输入时的行为及其作用可能并不总是显而易见的。 你可能会问自己: 如果传递空字符串或 null,此方法的行为如何? 如果有一套命名良好的单元测试,每个测试应清楚地解释给定输入的预期输出。 此外,测试应该能够验证它是否确实有效。

较少耦合的代码

当代码紧密耦合时,很难进行单元测试。 如果不为要编写的代码创建单元测试,耦合可能不太明显。 为代码编写测试会自然地对代码进行解耦,因为采用其他方法测试会更困难。

良好的单元测试的特征

定义良好的单元测试有几个重要特征:

  • 快速:对成熟项目进行数千次单元测试,这很常见。 单元测试应该只需很少的时间即可运行。 毫秒。
  • 独立:单元测试是独立的,可以隔离运行,并且不依赖于任何外部因素,例如文件系统或数据库。
  • 可重复:运行单元测试应与其结果一致。 如果未在运行之间更改任何内容,测试始终返回相同的结果。
  • 自我检查:测试应自动检测测试是否通过或失败,而无需任何人工交互。
  • 适时:与要测试的代码相比,编写单元测试不应花费过多不必要的时间。 如果发现与编写代码相比,测试代码需要花费大量时间,请考虑一种更具可测试性的设计。

代码覆盖率和代码质量

高代码覆盖率百分比通常与更高的代码质量相关联。 但是,度量本身 无法 确定代码的质量。 设置过于雄心勃勃的代码覆盖率百分比目标可能会适得其反。 考虑一个包含数千个条件分支的复杂项目,假设你设定了 95 个% 代码覆盖率的目标。 该项目当前维保持 90% 的代码覆盖率。 要覆盖剩余 5% 的所有边缘事例,需要花费巨大的工作量,而且价值主张也会迅速降低。

高代码覆盖率百分比不是成功指标,也不表示代码质量高。 它只是表示单元测试所涵盖的代码量。 有关详细信息,请参阅 单元测试代码覆盖率

单元测试术语

单元测试上下文中经常使用多个术语: 模拟存根。 遗憾的是,这些术语可能被误用,因此了解正确的用法非常重要。

  • Fake:Fake 是一个通用术语,可用于描述 stub 或 mock 对象。 对象是 stub 还是 mock 对象取决于使用该对象的上下文。 换句话说,Fake 可以是 stub 或 mock。

  • 模拟:模拟对象是系统中的假对象,它决定单元测试是通过还是失败。 mock 开始时为 fake,并且在进入 Assert 操作之前始终为 fake。

  • 存根:存根是系统中现有依赖项(或协作者)的可控制替代项。 通过使用存根,无需直接处理依赖项,即可测试代码。 默认情况下,stub 一开始为 fake。

请考虑以下代码:

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

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

此代码显示了一个被称为 mock 的 stub。 但在这种情况下,stub 确实是一个 stub。 代码的目的是传递顺序作为实例化 Purchase (受测系统)对象的方法。 类名 MockOrder 具有误导性,因为该命令是 stub 而不是 mock。

以下代码显示了更准确的设计:

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

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

当类重命名为 FakeOrder时,该类更泛型。 根据测试用例的要求,该类可用作 mock 或 stub。 在第一个示例中,FakeOrder 类用作存根,而在 Assert 操作期间则不使用。 该代码将 FakeOrder 类传递到 Purchase 类,只是为了满足构造函数的要求。

若要将类用作模拟,可以更新代码:

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

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

在此设计中,代码检查一个模拟对象的属性(对其进行断言),因此,mockOrder 类是一个模拟。

重要

正确使用术语非常重要。 如果将 stub 称为“mock”,其他开发人员对你的意图会做出错误的判断。

关于 mock 和 stub,最重要的一点是,除了 Assert 过程,mock 和 stub 是一样的。 可以针对 mock 对象运行 Assert 操作,但不能针对 stub 运行。

最佳做法

编写单元测试时,需要遵循几个重要的最佳做法。 以下部分提供了演示如何将最佳做法应用于代码的示例。

避免基础结构依赖项

编写单元测试时,请尝试不引入对基础结构的依赖项。 依赖项会使测试变慢且脆弱,应保留用于集成测试。 可以遵循 显式依赖项原则 并使用 .NET 依赖项注入来避免应用程序中的这些依赖项。 还可以将单元测试保留在与集成测试不同的项目中。 此方法可确保单元测试项目没有对基础结构包的引用或依赖项。

遵循测试命名标准

测试的名称应包含三个部分:

  • 要测试的方法的名称
  • 测试方法的情境
  • 调用方案时的预期行为

命名标准非常重要,因为它们有助于表达测试目的和应用程序。 测试不仅仅是确保代码正常工作。 它们还提供文档。 只需查看单元测试套件,即可推断代码的行为,不必查看代码本身。 此外,测试失败时,可以确切地看到哪些方案不符合预期。

原始代码

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

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

    Assert.Equal(0, actual);
}

应用最佳做法

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

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

    Assert.Equal(0, actual);
}

安排测试

“Arrange, Act, Assert” 模式是编写单元测试的常见方法。 顾名思义,模式由三个主要任务组成:

  • 排列对象、根据需要创建和配置对象
  • 对对象操作
  • 断言 某事符合预期

遵循模式时,可以清楚地将正在测试的内容与“排列”和“断言”任务分开。 该模式还有助于减少断言与 Act 任务中的代码混合的机会。

可读性是编写单元测试时最重要的方面之一。 在测试中分离每个模式动作可以清楚地突显出调用代码所需的依赖项、代码的调用方式,以及你试图验证的内容。 虽然可以组合一些步骤并减小测试的大小,但总体目标是使测试尽可能易于阅读。

原始代码

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

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

应用最佳做法

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

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

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

以最精简方式编写通过测试

单元测试的输入应该是验证当前正在测试的行为所需的最简单信息。 极简主义方法可帮助测试对代码库中未来的更改更具弹性,并专注于验证实现的行为。

包含比通过当前测试所需的信息更多的测试在测试中引入错误的可能性更高,并且可以使测试的意图不那么清晰。 编写测试时,需要专注于行为。 不必要地在模型上设置额外属性或使用非零值,只会削弱您尝试确认的目标。

原始代码

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

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

    Assert.Equal(42, actual);
}

应用最佳做法

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

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

    Assert.Equal(0, actual);
}

避免使用魔法字符串

Magic 字符串 是直接在单元测试中硬编码的字符串值,无需任何额外的代码注释或上下文。 这些值使代码不易于阅读且难以维护。 魔幻字符串可能会让测试读者感到困惑。 如果字符串看起来不寻常,他们可能会想知道为什么为参数或返回值选择了特定值。 这种类型的字符串值可能会导致他们更仔细地了解实现详细信息,而不是专注于测试。

小窍门

目标是在单元测试代码中表达尽可能多的意图。 不要使用 magic 字符串,而是将任何硬编码的值分配给常量。

原始代码

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

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

    Assert.Throws<OverflowException>(actual);
}

应用最佳做法

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

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

    Assert.Throws<OverflowException>(actual);
}

避免在单元测试中编写代码逻辑

编写单元测试时,请避免手动字符串串联、逻辑条件(如 ifwhileforswitch)和其他条件。 如果在测试套件中包含逻辑,则引入 bug 的可能性会显著增加。 你最不希望在测试套件中发现 bug。 你应该对你的测试有高度的信心,否则无法信任它们。 你不信任的测试,是没有价值的。 当测试失败时,你希望有一种强烈的感觉,即代码出了问题,并且不容忽视。

小窍门

如果在测试中添加逻辑似乎不可避免,请考虑将测试拆分为两个或更多不同的测试来限制逻辑要求。

原始代码

[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;
    }
}

应用最佳做法

[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);
}

使用帮助程序方法,而不是设置和解除

如果您的测试需要类似的对象或状态,请使用辅助方法,而不是使用属性 SetupTeardown(如果存在)。 出于多种原因,帮助程序方法优先于这些属性:

  • 读取测试时的混淆程度较低,因为每个测试中都可以看到所有代码
  • 给定测试的设置过多或过少的可能性降低
  • 在测试之间共享状态的可能性较小,这会在测试之间创建不需要的依赖项

在单元测试框架中,Setup 属性会在测试套件中的每次单元测试之前调用。 一些程序员认为此行为非常有用,但它通常会导致膨胀和难以阅读测试。 每个测试通常对设置和执行有不同的要求。 遗憾的是,属性 Setup 强制对每个测试使用相同的要求。

注释

xUnit 版本 2.x 及更高版本中删除了SetUpTearDown属性。

原始代码

应用最佳做法

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

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

    Assert.Equal(1, actual);
}
// More tests...
// More tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

避免多个 Act 任务

在编写测试时,尽量在每个测试中只包含一个 Act 任务。 实现单个 Act 任务的一些常见方法包括:为每个 Act 创建单独的测试,或者使用参数化测试。 每次测试使用单个 Act 任务有几个好处:

  • 如果测试失败,可以很容易地分辨出是哪个 Act 任务失败。
  • 可以确保测试只集中在单个案例上。
  • 你清楚地了解了测试失败的原因。

多个 Act 任务需要单独断言,而你无法保证所有 Assert 任务都能执行。 在大多数单元测试框架中,在单元测试中断言任务失败后,所有后续测试都将自动视为失败。 此过程可能会令人困惑,因为某些工作功能可能解释为失败。

原始代码

[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
    // Act
    var actual1 = stringCalculator.Add("");
    var actual2 = stringCalculator.Add(",");

    // Assert
    Assert.Equal(0, actual1);
    Assert.Equal(0, actual2);
}

应用最佳做法

[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
    // Arrange
    var stringCalculator = new StringCalculator();

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

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

使用公共方法验证专用方法

在大多数情况下,无需在代码中测试专用方法。 专用方法是一个具体的实现环节,从不孤立存在。 在开发过程中的某个时候,你会引入一个公共方法,以便调用私有方法作为实现的一部分。 在编写单元测试时,你关心的是调用专用方法的公共方法的最终结果。

请考虑以下代码方案:

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

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

在测试方面,你的第一反应可能是为 TrimInput 该方法编写测试,以确保其按预期工作。 然而,ParseLogLine 方法可能会以你意想不到的方式操纵 sanitizedInput 对象。 未知行为可能使您对 TrimInput 方法的测试变得无效。

在此场景中,较好的测试是验证面向公众的ParseLogLine方法。

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

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

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

遇到专用方法时,找到调用专用方法的公共方法,并针对公共方法编写测试。 仅仅因为私有方法返回预期结果,并不意味着最终调用专用方法的系统正确使用结果。

使用接缝处理 stub 静态引用

单元测试的一个原则是,它必须完全控制所测试的系统。 但是,当生产代码包含对静态引用的调用(例如, DateTime.Now)时,此原则可能会有问题。

查看以下代码场景:

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

是否可以为此代码编写单元测试? 您可以尝试在 price 上运行断言任务:

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);
}

遗憾的是,你很快意识到测试存在一些问题:

  • 如果测试套件在周二运行,则第二个测试通过,但第一个测试失败。
  • 如果测试套件在任何其他日期运行,则第一次测试通过,但第二次测试失败。

若要解决这些问题,需要将 接缝 引入生产代码。 一种方法是包装需要在接口中控制的代码,并使生产代码依赖于该接口:

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

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

还需要编写新版本的测试套件:

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 的值,并且可以在调用方法时替换任何值。