编写单元测试的最佳做法

您所在的位置:网站首页 netcore方法依赖注入 编写单元测试的最佳做法

编写单元测试的最佳做法

#编写单元测试的最佳做法| 来源: 网络整理| 查看: 265

.NET Core 和 .NET Standard 单元测试最佳做法 项目 06/05/2023

编写单元测试有许多优点;它们有助于回归、提供文档及辅助良好的设计。 然而,难懂且脆弱的单元测试会对代码库造成严重破坏。 本文介绍一些有关 .NET Core 和 .NET Standard 项目的单元测试设计的最佳做法。

本指南将介绍一些在编写单元测试时的最佳做法,使测试可复原且易于理解。

作者是 John Reese 且特别感谢 Roy Osherove

为什么要执行单元测试? 比执行功能测试节省时间

功能测试费用高。 它们通常涉及打开应用程序并执行你(或其他人)必须遵循的一系列步骤,以验证预期的行为。 测试人员可能并非总是了解这些步骤。 为了执行测试,他们需要联系更熟悉该领域的人。 对于细微更改,测试本身可能需要几秒钟,对于较大更改,可能需要几分钟。 最后,在系统中所做的每项更改都必须重复此过程。

而单元测试只需按一下按钮即可运行,只需要几毫秒时间,且无需测试人员了解整个系统。 测试通过与否取决于测试运行程序,而非测试人员。

防止回归

回归缺陷是在对应用程序进行更改时引入的缺陷。 通常,测试人员不仅要测试新功能,还要测试预先存在的功能,以验证先前实现的功能是否仍按预期运行。

使用单元测试,可在每次生成后,甚至在更改一行代码后重新运行整套测试。 让你确信新代码不会破坏现有功能。

可执行文档

在给定某个输入的情况下,特定方法的作用或行为可能并非总是很明显。 你可能会问自己:如果我将空白字符串传递给它,此方法会有怎样的行为? Null?

如果你有一套命名正确的单元测试,每个测试应能够清楚地解释给定输入的预期输出。 此外,它应该能够验证其确实有效。

减少耦合代码

当代码紧密耦合时,可能难以进行单元测试。 如果不为正在编写的代码创建单元测试,耦合度可能不太明显。

为代码编写测试会自然地解耦代码,因为采用其他方法测试会更困难。

优质单元测试的特征 快速:对成熟项目进行数千次单元测试,这很常见。 单元测试应该只需很少的时间即可运行。 几毫秒。 独立:单元测试是独立的,可以单独运行,并且不依赖文件系统或数据库等任何外部因素。 可重复:运行单元测试的结果应该保持一致,也就是说,如果在运行期间不更改任何内容,总是返回相同的结果。 自检查:测试应该能够在没有任何人工交互的情况下自动检测测试是否通过。 适时:与要测试的代码相比,编写单元测试不应花费过多不必要的时间。 如果发现测试代码与编写代码相比需要花费大量的时间,请考虑一种更易测试的设计。 代码覆盖率

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

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

让我们使用相同的术语

遗憾的是,当谈到测试时,术语“mock”经常被滥用。 以下几点定义了编写单元测试时最常见的 fake 类型:

Fake - Fake 是一个通用术语,可用于描述 stub 或 mock 对象。 它是 stub 还是 mock 取决于使用它的上下文。 也就是说,Fake 可以是 stub 或 mock。

Mock - Mock 对象是系统中的 fake 对象,用于确定单元测试是否通过。 Mock 起初为 Fake,直到对其断言。

Stub - Stub 是系统中现有依赖项(或协作者)的可控制替代项。 通过使用 Stub,可以在无需使用依赖项的情况下直接测试代码。 默认情况下,存根起初为 fake。

请思考以下代码片段:

var mockOrder = new MockOrder(); var purchase = new Purchase(mockOrder); purchase.ValidateOrders(); Assert.True(purchase.CanBeShipped);

前面的示例是将 stub 引用为 mock。 在本例中,它就是 stub。 只是将 Order 作为实例化 Purchase(被测系统)的一种方法传递。 名称 MockOrder 也具有误导性,因为同样的,order 不是 mock。

更好的方法是:

var stubOrder = new FakeOrder(); var purchase = new Purchase(stubOrder); purchase.ValidateOrders(); Assert.True(purchase.CanBeShipped);

通过将类重命名为 FakeOrder,类变得更通用。 此类可以用作 mock 或 stub,哪种更适合测试用例就用哪种。 在前面的示例中,FakeOrder 用作 stub。 在断言期间,没有以任何形状或形式使用 FakeOrder。 FakeOrder 传递到 Purchase 类,以满足构造函数的要求。

若要将其用作 Mock,可按照如下代码进行操作:

var mockOrder = new FakeOrder(); var purchase = new Purchase(mockOrder); purchase.ValidateOrders(); Assert.True(mockOrder.Validated);

在这种情况下,检查 Fake 上的属性(针对其进行断言),因此在前面的代码片段中,mockOrder 是 Mock。

重要

正确理解此术语至关重要。 如果将 stub 称为“mock”,其他开发人员对你的意图会做出错误的判断。

关于 mock 与 stub,要记住的是 mock 与 stub 很像,但可以针对 mock 对象进行断言,而不是针对 stub 进行断言。

最佳实践

编写单元测试时,尽量不要引入基础结构依赖项。 依赖项会降低测试速度,使测试更加脆弱,应将其保留供集成测试使用。 可以通过遵循 Explicit Dependencies Principle(显式依赖项原则)和使用 Dependency Injection(依赖项注入)避免应用程序中的这些依赖项。 还可以将单元测试保留在单独的项目中,与集成测试相分隔。 此方法可确保单元测试项目没有引用或依赖基础结构包。

为测试命名

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

要测试的方法的名称。 测试的方案。 调用方案时的预期行为。 为什么?

命名标准非常重要,因为它们明确地表达了测试的意图。 测试不仅能确保代码有效,还能提供文档。 只需查看单元测试套件,就可以在不查看代码本身的情况下推断代码的行为。 此外,测试失败时,你可以确切地看到不符合预期的方案。

不佳: [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”是单元测试时的常见模式。 顾名思义,它包含三个主要操作:

安排对象,根据需要对其进行创建和设置。 作用于对象。 断言某些项按预期进行。 为什么? 明确地将要测试的内容从“arrange”和“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 字符串。

为什么? 测试读者无需检查生产代码即可了解值的特殊之处。 明确地显示所要证明的内容,而不是显示要完成的内容 。

魔幻字符串可能会让测试读者感到困惑。 如果字符串看起来不寻常,他们可能想知道为什么为参数或返回值选择某个值。 这种类型的字符串值可能会使他们更仔细地查看实现细节,而不是专注于测试。

提示

编写测试时,应力求表达尽可能多的意图。 对于魔幻字符串,一种很好的方法是将这些值赋给常量。

不佳: [Fact] public void Add_BigNumber_ThrowsException() { var stringCalculator = new StringCalculator(); Action actual = () => stringCalculator.Add("1001"); Assert.Throws(actual); } 良好: [Fact] void Add_MaximumSumResult_ThrowsOverflowException() { var stringCalculator = new StringCalculator(); const string MAXIMUM_RESULT = "1001"; Action actual = () => stringCalculator.Add(MAXIMUM_RESULT); Assert.Throws(actual); } 在测试中应避免逻辑

编写单元测试时,请避免手动字符串串联、逻辑条件(例如 if、while、for 和 switch)以及其他条件。

为什么? 降低在测试中引入 bug 的可能性。 专注于最终结果,而不是实现细节。

将逻辑引入测试套件中时,引入 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); } 更偏好 helper 方法而非 setup 和 teardown

如果测试需要类似的对象或状态,那么比起使用 Setup 和 Teardown 属性(如果存在),首选使用 helper 方法。

为什么? 读者阅读测试时产生的困惑减少,因为每个测试中都可以看到所有代码。 给定测试的设置过多或过少的可能性降低。 在测试之间共享状态(这会在测试之间创建不需要的依赖项)的可能性降低。

在单元测试框架中,在测试套件的每个单元测试之前调用 Setup。 虽然有些人可能将其视为有用的工具,但它通常最终会导致庞大且难以阅读的测试。 每个测试通常有不同的要求,以使测试启动并运行。 遗憾的是,Setup 迫使你对每个测试使用完全相同的要求。

注意

自版本 2.x 起,xUnit 已删除 SetUp 和 TearDown

不佳: 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); } 良好: [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(); } 避免多个操作

在编写测试时,请尝试每次测试只包含一个操作。 仅使用一个操作的常用方法包括:

为每个操作创建单独的测试。 使用参数化测试。 为什么? 测试失败时,无法确定哪个操作失败。 确保测试仅侧重于单个用例。 让你从整体上了解测试失败原因。

需要单独断言多个操作,并且不能保证所有断言都会被执行。 在大多数单元测试框架中,一旦断言在单元测试中失败,则正在进行中的测试会自动被视为失败。 此类过程可能会令人困惑,因为实际运行的功能将显示为失败。

不佳: [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; } }

如何对此代码进行单元测试? 你可能会尝试一种方法,例如:

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

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

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

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

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(); 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(); dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday); var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub); Assert.Equals(1, actual); }

现在,测试套件可以完全控制 DateTime.Now,并且在调用方法时可以存根任何值。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3