C# 单元测试详解

在前面,我们研究了异常处理,如何正确地实现它,以及在出现问题时如何对客户和程序员有用。在本章中,我们将介绍程序员如何实现自己的质量保证质量保证),以提供健壮且不太可能在生产中产生异常的质量代码。

我们首先看看为什么我们应该测试自己的代码,以及什么是好的测试。然后,我们来看看 C# 程序员可以使用的几种测试工具。然后,我们继续讨论单元测试的三大支柱,即失败、通过和重构。最后,我们看一下冗余单元测试以及为什么应该删除它们。

在本章中,我们将介绍以下主题:

本章结束时,您将获得以下技能:

  • 能够描述好代码的好处
  • 能够描述非单元测试可能产生的潜在负面影响
  • 能够安装并使用 MSTest 编写和运行单元测试
  • 能够安装并使用 NUnit 编写和运行单元测试
  • 能够安装并使用 Moq 编写假(模拟)对象
  • 能够安装并使用 SpecFlow 编写符合客户规范的软件
  • 能够编写失败的测试,然后让它们通过,然后执行任何必要的重构

要访问本章的代码文件,您可以访问此链接:https://github.com/PacktPublishing/Clean-Code-in-C-/tree/master/CH06

作为一名程序员,从事一个你觉得有趣的新的开发项目是很好的,特别是如果你有很强的动机去做的话。但是,如果你被叫去处理一个 bug,那会非常令人沮丧。如果不是您的代码,并且您没有完全理解代码背后的含义,那么情况可能会更糟。更糟糕的是,如果这是你自己的代码,你有那个“我在想什么?”时刻!您从新开发中被要求对现有代码执行维护的次数越多,您就越开始意识到单元测试的必要性。随着这种欣赏的增长,您开始看到学习测试方法和技术的真正好处,如测试驱动开发(TDD)行为驱动开发(BDD)

当你花了一段时间作为维护程序员处理别人的代码时,你会看到好的、坏的和丑陋的。这样的代码可以是一种积极的教育,通过了解不做什么和为什么不做,让您了解更好的编程方式。坏代码会让你大叫不,只是不!丑陋的代码会导致你的眼睛流血,大脑麻木。

直接与客户打交道,为他们提供技术支持,您会发现良好的客户体验对于业务的成功是多么重要。相反,你也可以看到糟糕的客户体验如何导致一些非常沮丧、愤怒和满嘴脏话的客户;还有,由于客户退款,以及由于社交媒体和评论网站上非常有害的客户咆哮而导致客户流失,销售会以多快的速度流失。

作为技术负责人,您有责任进行技术代码审查,以确保员工遵守公司的编码指导方针和政策,分类错误,并协助项目经理管理您负责领导的人员。作为技术领先者,擅长高级项目管理、需求收集和分析、架构设计和干净的编程非常重要。你还需要有良好的人际交往能力。

您的项目经理只对按时交付项目感兴趣,并根据业务需要进行预算。他们真的不在乎你如何编写软件,只在乎你按时完成并达到商定的预算。最重要的是,他们关心的是,发布的软件完全符合企业的要求——不多也不少——并且软件达到了非常高的专业标准,因为代码的质量同样可以提升或摧毁公司品牌。当一个项目经理对你苛刻时,你知道企业正在给他们增加压力。所以压力会慢慢地流到你身上。

作为技术负责人,你被夹在项目经理和项目团队之间。在您的日常工作中,您将运行 scrum 会议并处理问题。这些问题可能是程序员需要分析师提供的资源,测试人员等待开发人员修复 bug,等等。但最困难的工作将是执行同行代码审查,并提供建设性的反馈,从而在不冒犯他人的情况下获得预期的结果。这就是为什么您应该非常认真地对待干净的编码,因为如果您批评一个人的代码,那么如果您自己的代码不符合标准,您将面临强烈的反弹。而且,如果软件测试失败或出现大量 bug,您将成为项目经理的得力助手。

这就是为什么,作为一名技术领先者,鼓励 TDD 是一个好主意。最好的方法是以身作则。现在我知道,即使是受过大学教育、经验丰富的程序员也可能对 TDD 非常冷淡。其中一个最常见的原因是,它可能很难学习并付诸实践,而且似乎更耗时,尤其是当代码变得更复杂时。我的同事们不喜欢单元测试,我也遇到过这样的反对意见。

但是作为一个程序员,如果你想真正有信心(比如一旦你写了一段代码,你就可以对它的质量有信心,并且它不会返回给你来修复你自己的 bug),那么 TDD 是一个非常好的方式来提升你作为一个程序员的游戏。当你在开始编程之前先学会测试时,它很快就会成为习惯。作为一名程序员,这样的习惯对你非常有用和有益,尤其是当你需要找到一个新的职位时,因为很多就业机会都在招聘有 TDD 或 BDD 经验的人。

编写代码时要考虑的另一件事是,一个简单的、非关键性的笔记应用中的 bug 并不是世界末日。但是如果你在国防或卫生部门工作呢?考虑一种大规模杀伤性武器,它已经被编程为在特定的方向上在敌方领土击中特定目标,但有些事情出错了,而导弹瞄准的是属于你的盟友的平民百姓。或者,考虑一下如果你有一个爱你的人因为你在医疗设备软件中的一个错误而死去,那是你自己的错。那么,如果客机在人口稠密地区上空飞行时出现安全软件故障,导致飞机坠入地面,造成机上和地面人员死亡,又该怎么办呢?

软件越关键,就越需要认真使用单元测试技术(如 TDD 和 BDD)。我们将在本章后面讨论 BDD 和 TDD 工具。在编写软件时,想想如果您是客户,并且您正在编写的代码出现了问题,您会受到怎样的影响。它会如何影响你的家人、朋友和同事?此外,如果你要为一次严重的失败负责,想想道德和法律方面的影响。

作为一名程序员,理解为什么要学习测试自己的代码是很重要的。他们说“程序员永远不应该测试自己的代码”是真的。但只有在代码完成并在投入生产之前准备好测试的情况下,才是这样。因此,当代码仍在编程时,程序员应该始终测试自己的代码。然而,一些业务由于时间限制,常常牺牲适当的 QA,以便业务能够首先进入市场。

对于一家企业来说,首先进入市场可能非常重要,但第一印象很重要。如果一个企业首先进入市场,并且该产品存在一些严重缺陷并在全球范围内传播,这可能会对一个企业产生长期的负面影响。因此,作为一名程序员,您必须非常仔细地思考,并尽最大努力确保如果软件存在缺陷,您不是负责人。当一家企业出现问题时,人们会感到沮丧。在 Teflon 管理中,管理者们会把自己推到荒谬的截止日期的罪恶感从自己身上传递到程序员身上,而程序员必须在截止日期前完成任务,并为此做出牺牲。

因此,作为一名程序员,测试代码并经常进行测试是非常重要的,尤其是在将代码发布给测试团队之前。这就是为什么我们会积极鼓励您转变思维方式和习惯行为,首先根据您当前实现的规范编写测试。你的测试一开始就应该失败。然后只编写足够的代码以通过测试,然后根据需要重构代码。

很难开始使用 TDD 或 BDD。但是一旦你掌握了窍门,TDD 和 BDD 就成了你的第二天性。您可能会发现,从长远来看,您只剩下更干净、易于阅读和维护的代码。您还可能会发现,您对在不破坏代码的情况下修改代码的能力的信心也会大大提高。显然,在您拥有生产方法和测试方法的意义上,还有更多的代码。但实际上,您最终可能会编写更少的代码,因为您不会添加您认为可能需要的额外代码!

想象你自己在你的电脑前,用一个你必须翻译成工作软件的软件规范。许多程序员都有一个坏习惯,我过去也犯过这个毛病,那就是他们不做任何真正的设计工作就直接开始编码。根据我的经验,这实际上延长了开发一段代码所需的时间,并且常常会导致更多的 bug 和难以维护和扩展的代码。事实上,尽管对某些程序员来说这似乎是违反直觉的,但正确的规划和设计实际上加快了编码速度,特别是当您考虑到维护和扩展时。

这就是测试团队的作用所在。在进一步讨论之前,让我们描述一下用例、测试设计、测试用例和测试套件,以及它们之间的关系。

用例解释了单个操作的流程,例如添加客户记录。测试设计将包括一个或多个测试用例,这些测试用例针对单个用例可能发生的不同场景进行测试。测试用例可以手动执行,也可以是由测试套件执行的自动化测试。测试套件是一种软件,用于发现和运行测试,并向最终用户报告测试结果。用例的编写将由业务分析师负责。至于测试设计、测试用例和测试套件,这些将由专门的测试团队负责。开发人员不必关心将用例、测试用例的测试设计以及它们在测试套件中的执行放在一起。开发人员必须专注于编写和使用他们的单元测试来编写失败的代码,然后运行,然后根据需要进行重构。

软件测试人员与程序员合作。这种协作通常从项目开始时开始,一直持续到项目结束。开发团队和测试团队将通过共享每个产品待办事项的测试用例进行协作。这个过程通常包括编写测试用例。为了通过测试,他们必须满足测试标准。这些测试用例通常使用手动测试和一些测试套件自动化的组合来运行。

在开发阶段,测试人员编写 QA 测试,开发人员编写单元测试。当开发人员向测试团队提交代码时,测试团队将运行他们的测试电池。这些测试的结果将反馈给开发人员和项目干系人。如果遇到问题,这就是所谓的技术债务。开发团队必须及时考虑解决测试团队提出的问题。当测试团队确认软件已完成到所需的质量级别时,代码将传递到基础架构以发布到生产中。

假设我们正在启动一个全新的项目(也称为绿地项目),我们将选择适当的项目类型,并勾选包含测试项目的选项。这将创建一个由主项目和测试项目组成的解决方案。

我们创建的项目类型和要实现的项目的任何特性都将取决于用例。用例在系统分析期间用于识别、确认和组织软件需求。从用例中,可以将测试用例分配给验收标准。作为程序员,您可以使用这些用例及其测试用例为每个测试用例构建自己的单元测试。然后,您的测试将作为测试套件的一部分运行。在 Visual Studio 2019 中,您可以从查看|测试资源管理器菜单访问测试资源管理器。当您构建项目时,将发现测试。发现测试后,将在测试资源管理器中查看这些测试。然后可以在测试资源管理器中运行和/或调试测试。

在这个阶段值得注意的是,设计测试并提出适当数量的测试用例是测试人员的责任,而不是开发人员的责任。一旦软件离开开发人员的手中,他们还负责 QA。但是,单元测试代码仍然是开发人员的责任,在这里,测试用例可以成为在代码中编写单元测试的真正帮助和动力。

创建解决方案时,首先要打开提供的测试类。在该测试类中,您为必须完成的任务编写伪代码。然后一步一步地遍历伪代码,并添加测试方法,以测试必须完成的每个步骤,从而达到完成软件项目的目标。您编写的每个测试方法都被写为失败。然后编写足够的代码以通过测试。然后,一旦测试通过,在进行下一个测试之前重构代码。所以,你可以看到单元测试不是火箭科学。但是写一个好的单元测试需要什么呢?

任何正在测试的代码都将提供特定的功能。函数接受输入并产生输出。

在正常运行的计算机程序中,方法(或函数)的输入和输出范围为可接受的范围,输入和输出范围为不可接受的。因此,完美单元测试将测试最低的可接受值,最高的可接受值,并将提供超出可接受值范围的测试用例,包括高值和低值。

单元测试必须是原子测试,这意味着它们只应该测试一件事情。由于方法可以在同一个类中链接在一起,甚至可以跨多个程序集中的多个类链接在一起,因此为测试中的类提供伪对象或模拟对象以保持它们的原子性通常是有用的。输出必须确定是通过还是失败。好的单元测试决不能是非决定性的。

测试结果应该是可重复的,因为它在给定条件下总是通过或失败。也就是说,一次又一次地运行同一个测试不应该在每次运行时产生不同的结果。如果是,那么它是不可重复的。单元测试不应该依赖于之前运行的其他测试,它们应该与其他方法和类隔离。您还应该瞄准以毫秒为单位运行的单元测试。任何需要一秒钟或更长时间运行的测试都太长。如果代码占用的时间比秒长,那么您应该考虑重构或实现模拟对象进行测试。由于我们是忙碌的程序员,单元测试应该很容易设置,不需要大量的编码或配置。下图显示了单元测试生命周期:

在本章中,我们将编写单元测试和模拟对象。但在此之前,我们需要先看看 C# 程序员可以使用的一些工具。

我们将在 Visual Studio 中查看的测试工具有MSTestNUnitMoqSpecFlow。每个测试工具都创建一个控制台应用和相关的测试项目。NUnit 和 MSTest 是单元测试框架。NUnit 比 MSTest 旧得多,因此与 MSTest 相比,它有一个更成熟、功能更全面的 API。我个人更喜欢 NUnit 而不是 MSTest。

Moq 不同于 MSTest 和 NUnit,因为它不是一个测试框架,而是一个模拟框架。模拟框架用用于测试目的的模拟(伪)实现替换项目中的真实类。您可以将 Moq 与 MSTest 或 NUnit 一起使用。最后,SpecFlow 是一个 BDD 框架。首先,使用用户和技术人员都能理解的业务语言在功能文件中编写功能。然后为该功能生成一个 step 文件。步骤文件包含实现该功能所需的步骤。

在本章结束时,您将了解每个工具的功能,并能够在您自己的项目中使用它们。那么,让我们从 MSTest 开始。

MSTest

在本节中,我们将安装和配置 MSTest 框架。我们将用测试方法编写一个测试类并对其进行初始化。我们将执行程序集设置和清理、类清理和方法清理,并执行断言。

要从 Visual Studio 中的命令行安装 MSTest Framework,需要通过工具| NuGet Package Manager | Package Manager 控制台打开 Package Manager 控制台:

然后,运行以下三个命令来安装 MSTest Framework:

install-package mstest.testframework
install-package mstest.testadapter
install-package microsoft.net.tests.sdk

或者,您可以添加一个新项目,并从解决方案资源管理器中的上下文添加菜单中选择单元测试项目(.NET Framework)。请参见下面的屏幕截图。测试项目命名时,验收标准采用<ProjectName>.Tests形式。这有助于将它们与测试关联起来,并将它们与正在测试的项目区分开来:

以下代码是将 MSTest 项目添加到解决方案时生成的默认单元测试代码。如您所见,该类导入了Microsoft.VisualStudio.TestTools.UnitTesting名称空间。[TestClass]属性向 MS 测试框架标识该类是一个测试类。[TestMethod]属性将该方法标记为测试方法。所有具有[TestMethod]属性的职业将出现在测试玩家中。[TestClass][TestMethod]属性是强制性的:

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace CH05_MSTestUnitTesting.Tests
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
        }
    }
}

还有其他方法和属性可以选择性地组合以生成完整的测试执行工作流。其中包括[AssemblyInitialize][AssemblyCleanup][ClassInitialize][ClassCleanup][TestInitialize][TestCleanup]。正如其名称所暗示的,初始化属性用于在运行测试之前在程序集、类和方法级别执行任何初始化。同样,在运行测试以执行任何必要的清理操作后,清理属性将在方法、类和程序集级别运行。我们将依次查看每一个,并将它们添加到您的项目中,因为我们将在运行最终代码时看到它的执行顺序。

WriteSeparatorLine()方法是一种辅助方法,用于分离我们的测试方法输出。这将帮助我们更轻松地了解我们的测试课程:

private static void WriteSeparatorLine()
{
    Debug.WriteLine("--------------------------------------------------");
}

或者,在执行测试之前,分配[AssemblyInitialize]属性以执行代码:

[AssemblyInitialize]
public static void AssemblyInit(TestContext context)
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: AssemblyInitialize");
    Debug.WriteLine("Executes once before the test run.");
}

然后,您可以选择分配[ClassInitialize]属性,以便在执行测试之前执行代码一次:

[ClassInitialize]
public static void TestFixtureSetup(TestContext context)
{
    WriteSeparatorLine();
    Console.WriteLine("Optional: ClassInitialize");
    Console.WriteLine("Executes once for the test class.");
}

然后,通过将[TestInitialize]属性分配给设置方法,在每个单元测试之前运行设置代码:

[TestInitialize]
public void Setup()
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: TestInitialize");
    Debug.WriteLine("Runs before each test.");
}

完成测试运行后,您可以选择分配[AssemblyCleanup]属性以执行任何必要的清理操作:

[AssemblyCleanup]
public static void AssemblyCleanup()
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: AssemblyCleanup");
    Debug.WriteLine("Executes once after the test run.");
}

标记为[ClassCleanup]的可选方法在类中的所有测试执行完毕后运行一次。您无法保证此方法何时运行,因为它可能不会在执行所有测试后立即运行:

[ClassCleanup]
public static void TestFixtureTearDown()
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: ClassCleanup");
    Debug.WriteLine("Runs once after all tests in the class have been 
     executed.");
    Debug.WriteLine("Not guaranteed that it executes instantly after all 
     tests the class have executed.");
}

要在每个测试运行后执行清理操作,请将[TestCleanup]属性应用于测试清理方法:

[TestCleanup]
public void TearDown()
{
    WriteSeparatorLine();
    Debug.WriteLine("Optional: TestCleanup");
    Debug.WriteLine("Runs after each test.");
    Assert.Fail();
}

现在我们的代码已经就位,构建它。然后,从测试菜单中选择测试资源管理器。您应该在测试资源管理器中看到以下测试。从以下屏幕截图可以看出,测试尚未运行:

那么,让我们运行我们唯一的测试。哦,不!我们的测试失败,如以下屏幕截图所示:

更新TestMethod1()代码,如下代码段所示,然后再次运行测试:

[TestMethod]
public void TestMethod1()
{
    WriteSeparatorLine();
    Debug.WriteLine("Required: TestMethod");
    Debug.WriteLine("A test method to be run by the test runner.");
    Debug.WriteLine("This method will appear in the test list.");
    Assert.IsTrue(true);
}

您可以在测试资源管理器中看到测试已通过,如下面的屏幕截图所示:

所以,从前面的截图中可以看到,没有执行的测试是蓝色,失败的测试是红色,通过的测试是绿色。从工具|选项|调试|常规中,选择将所有输出窗口文本重定向到即时窗口。然后,选择运行|调试所有测试。

当您运行测试并将输出打印到即时窗口时,属性的执行顺序将变得很明显。以下屏幕截图显示了我们测试方法的输出:

正如您已经看到的,我们使用了两种Assert方法,即Assert.Fail()Assert.IsTrue(true)Assert类非常有用,因此了解类中可用于单元测试的方法是值得的。以下列出并描述了这些可用方法:

| 方法 | 说明 | | Assert.AreEqual() | 测试指定的值是否相等,如果两个值不相等,则引发异常。 | | Assert.AreNotEqual() | 测试指定的值是否不相等,如果两个值相等,则引发异常。 | | Assert.ArtNotSame() | 测试指定的对象是否引用不同的对象,并在两个输入引用同一对象时引发异常。 | | Assert.AreSame() | 测试指定的对象是否都引用同一对象,如果两个输入不引用同一对象,则引发异常。 | | Assert.Equals() | 此对象将始终与Assert.Fail一起抛出。因此,我们可以使用Assert.AreEqual代替。 | | Assert.Fail() | 抛出一个AssertFailedException异常。 | | Assert.Inconclusive() | 抛出一个AssertInconclusiveException异常。 | | Assert.IsFalse() | 测试指定的条件是否为 false,如果条件为 true,则引发异常。 | | Assert.IsInstanceOfType() | 测试指定对象是否为预期类型的实例,如果预期类型不在对象的继承层次结构中,则引发异常。 | | Assert.IsNotInstanceOfType() | 测试指定对象是否为错误类型的实例,如果指定类型位于对象的继承层次结构中,则引发异常。 | | Assert.IsNotNull() | 测试指定的对象是否为非 null,如果为 null,则引发异常。 | | Assert.IsNull() | 测试指定的对象是否为 null,如果不为 null,则引发异常。 | | Assert.IsTrue() | 测试指定的条件是否为 true,如果条件为 false,则引发异常。 | | Assert.ReferenceEquals() | 确定指定的对象实例是否为同一实例。 | | Assert.ReplaceNullChars() | 将空字符('\0'替换为“\\0”。 | | Assert.That() | 获取Assert功能的单例实例。 | | Assert.ThrowsException() | 测试委托操作指定的代码是否引发类型为T(而非派生类型)的给定异常,如果代码未引发异常,则是否引发AssertFailedException,或者是否引发类型为T以外的异常。简单地说,它接受一个委托,并断言它会抛出预期的异常和预期的消息。 | | Assert.ThrowsExceptionAsync() | 测试委托操作指定的代码是否引发类型为T(而非派生类型)的给定异常,如果代码未引发异常,则是否引发AssertFailedException,或者是否引发类型为T以外的异常。 |

现在我们已经了解了 MSTest,是时候了解一下 NUnit 了。

单元测试

如果未为 Visual Studio 安装 NUnit,请通过扩展|管理扩展下载并安装它。之后,创建一个新的 NUnit 测试项目(.NET Core)。以下代码包含 NUnit 创建的默认类,名为Tests

public class Tests
{
    [SetUp]
    public void Setup()
    {
    }

    [Test]
    public void Test1()
    {
        Assert.Pass();
    }
}

正如您从Test1方法中看到的,测试方法也使用Assert类,MSTest 用于测试代码中的断言。NUnit Assert 类为我们提供了以下方法(请注意,下表中标记为[NUnit]的方法特定于 NUnit;所有其他方法也存在于 MSTest 中):

| 方法 | 说明 | | Assert.AreEqual() | 验证两项是否相等。如果它们不相等,则抛出异常。 | | Assert.AreNotEqual() | 验证两项是否不相等。如果它们相等,则抛出异常。 | | Assert.AreNotSame() | 验证两个对象是否引用同一个对象。如果他们这样做,则抛出异常。 | | Assert.AreSame() | 验证两个对象是否引用同一个对象。如果没有,则抛出异常。 | | Assert.ByVal() | [NUnit]将约束应用于实际值,如果满足约束,则成功,并在失败时引发断言异常。在极少数情况下,当私有 setter 导致 Visual Basic 编译错误时,用作That的同义词。 | | Assert.Catch() | [NUnit]验证委托在调用时是否引发异常并返回异常。 | | Assert.Contains() | [NUnit]验证集合中是否包含值。 | | Assert.DoesNotThrow() | [NUnit]验证方法是否不会引发异常。 | | Assert.Equal() | [NUnit]不要使用。用Assert.AreEqual()代替。 | | Assert.Fail() | 抛出一个AssertionException。 | | Assert.False() | [NUnit]验证条件是否为 false。如果条件为 true,则引发异常。 | | Assert.Greater() | [NUnit]验证第一个值是否大于第二个值。如果不是,则引发异常。 | | Assert.GreaterOrEqual() | [NUnit]验证第一个值是否大于或等于第二个值。如果不是,则引发异常。 | | Assert.Ignore() | [NUnit]使用传入的消息和参数抛出IgnoreException。这会导致将测试报告为已忽略。 | | Assert.Inconclusive() | 使用传入的消息和参数抛出InconclusiveException。这导致测试报告为不确定。 | | Assert.IsAssignableFrom() | [NUnit]验证是否可以为对象分配给定类型的值。 | | Assert.IsEmpty() | [NUnit]验证字符串或集合等值是否为空。 | | Assert.IsFalse() | 验证条件是否为 false。如果为 true,则引发异常。 | | Assert.IsInstanceOf() | [NUnit]验证对象是否为给定类型的实例。 | | Assert.NAN() | [NUnit]验证该值不是数字。如果是,则抛出异常。 | | Assert.IsNotAssignableFrom() | [NUnit]验证对象是否不可从给定类型分配。 | | Assert.IsNotEmpty() | [NUnit]验证字符串或集合是否为空。 | | Asserts.IsNotInstanceOf() | [NUnit]验证对象是否不是给定类型的实例。 | | Assert.InNotNull() | 验证对象是否为空。如果是,则抛出异常。 | | Assert.IsNull() | 验证对象是否为空。如果不是,则抛出异常。 | | Assert.IsTrue() | 验证条件是否为真。如果为 false,则抛出异常。 | | Assert.Less() | [NUnit]验证第一个值是否小于第二个值。如果不是,则抛出异常。 | | Assert.LessOrEqual() | [NUnit]验证第一个值是否小于或等于第二个值。如果不是,则抛出异常。 | | Assert.Multiple() | [NUnit]包装包含一系列断言的代码,即使这些断言失败,也应该执行这些断言。失败的结果将保存并报告在代码块的末尾。 | | Assert.Negative() | [NUnit]验证数字是否为负数。如果不是,则抛出异常。 | | Assert.NotNull() | [NUnit]验证对象是否为空。如果为 null,则引发异常。 | | Assert.NotZero() | [NUnit]验证一个数字不是零。如果为零,则引发异常。 | | Assert.Null() | [NUnit]验证对象是否为空。如果不是,则抛出异常。 | | Assert.Pass() | [NUnit]使用传入的消息和参数抛出SuccessException。这允许缩短测试,并将成功结果返回给 NUnit。 | | Assert.Positive() | [NUnit]验证数字是否为正数。 | | Assert.ReferenceEquals() | [NUnit]不要使用。抛出InvalidOperationException。 | | Assert.That() | 验证条件是否为真。如果不是,则抛出异常。 | | Assert.Throws() | 验证委托在被调用时是否引发特定异常。 | | Assert.True() | [NUnit]验证条件是否为真。如果不是,则调用异常。 | | Assert.Warn() | [NUnit]使用提供的消息和参数发出警告。 | | Assert.Zero() | [NUnit]验证数字是否为零。 |

NUnit 生命周期从第一次测试SetUp之前执行一次的TestFixtureSetup开始。然后,在每次测试前执行SetUp。每次测试完成后,执行TearDown。最后,在最后一次测试TearDown之后执行一次TestFixtureTearDown。我们现在将更新Tests类,以便调试并查看 NUnit 的生命周期:

using System;
using System.Diagnostics;
using NUnit.Framework;

namespace CH06_NUnitUnitTesting.Tests
{
    [TestFixture]
    public class Tests : IDisposable
    {
        public TestClass()
        {
            WriteSeparatorLine();
            Debug.WriteLine("Constructor");
        }

        public void Dispose()
        {
            WriteSeparatorLine();
            Debug.WriteLine("Dispose"); 
        } 
    }
}

我们已经在类中添加了[TestFixture]并实现了IDisposable接口。对于非参数化和非常规装置,[TextFixture]属性是可选的。只要至少有一个方法标记有[Test][TestCase][TestCaseSource]属性,则类将被视为[TextFixture]

WriteSeparatorLine()方法充当调试输出的分隔符。此方法将在Tests类中所有方法的顶部调用:

private static void WriteSeparatorLine()
{
 Debug.WriteLine("--------------------------------------------------");
}

在运行该类中的任何测试之前,标记有[OneTimeSetUp]属性的方法将只运行一次。所有不同测试所需的任何初始化将在此处执行:

[OneTimeSetUp]
public void OneTimeSetup()
{
    WriteSeparatorLine();
    Debug.WriteLine("OneTimeSetUp");
    Debug.WriteLine("This method is run once before any tests in this 
     class are run.");
}

标记为[OneTimeTearDown]的方法在所有测试运行后,在类处理之前运行一次:

[OneTimeTearDown]
public void OneTimeTearDown()
{
    WriteSeparatorLine();
    Debug.WriteLine("OneTimeTearDown");
    Debug.WriteLine("This method is run once after all tests in this 
    class have been run.");
    Debug.WriteLine("This method runs even when an exception occurs.");
}

标记有[Setup]属性的方法在每个测试方法之前运行一次:

[SetUp]
public void Setup()
{
    WriteSeparatorLine();
    Debug.WriteLine("Setup");
    Debug.WriteLine("This method is run before each test method is run.");
}

标记有[TearDown]属性的方法在每个测试方法完成后运行一次:

[TearDown]
public void Teardown()
{
    WriteSeparatorLine();
    Debug.WriteLine("Teardown");
    Debug.WriteLine("This method is run after each test method 
     has been run.");
    Debug.WriteLine("This method runs even when an exception occurs.");
}

Test2()方法是由[Test]属性表示的测试方法,并且将是由[Order(1)]属性确定的第二个运行的测试方法。此方法抛出InconclusiveException

  [Test]
  [Order(1)]
  public void Test2()
  {
      WriteSeparatorLine();
      Debug.WriteLine("Test:Test2");
      Debug.WriteLine("Order: 1");
      Assert.Inconclusive("Test 2 is inconclusive.");
  }

Test1()方法是由[Test]属性表示的测试方法,并且将是由[0rder(0)]属性确定的第一个要运行的测试方法。方法通过SuccessException

[Test]
[Order(0)]
public void Test1()
{
    WriteSeparatorLine();
    Debug.WriteLine("Test:Test1");
    Debug.WriteLine("Order: 0");
    Assert.Pass("Test 1 passed with flying colours.");
}

Test3()方法是由[Test]属性表示的测试方法,并且将是由[Order(2)]属性确定的第三个要运行的测试方法。方法抛出AssertionException

[Test]
[Order(2)]
public void Test3()
{
    WriteSeparatorLine();
    Debug.WriteLine("Test:Test3");
    Debug.WriteLine("Order: 2");
    Assert.Fail("Test 1 failed dismally.");
}

调试所有测试时,即时窗口应如以下屏幕截图所示:

您现在已经接触了 MSTest 和 NUnit,并且已经看到了每个框架的测试生命周期。现在是时候看看最低起订量了。

从 NUnit 方法表(与 MSTest 方法表相比)可以看出,NUnit 支持比 MSTest 更细粒度的单元测试,并以更好的性能执行,这就是为什么它比 MSTest 得到更广泛的应用。

最小起订量

单元测试应该只测试被测试的方法。请参见下图。如果被测试的方法调用当前类或不同类中的其他方法,则不仅测试方法,还测试其他方法:

克服这一问题的一种方法是使用模拟(伪)对象。模拟对象将只测试您想要测试的方法,并且您可以使模拟对象以您想要的任何方式工作。如果您要编写自己的模拟对象,您很快就会意识到这涉及到很多艰苦的工作。这在时间敏感的项目中可能是不可接受的,代码越复杂,模拟对象就越复杂。

您将不可避免地放弃它,因为它是一项糟糕的工作,或者您将寻找一个适合您需要的模拟框架。Rhino Mock 和 Moq 是.NET 框架的两个模拟框架。在本章中,我们将只讨论 Moq,它比 Rhino Mock 更易于学习和使用。有关犀牛模型的更多信息,请访问http://hibernatingrhinos.com/oss/rhino-mocks

当使用 Moq 进行测试时,我们首先添加 mock 对象,然后配置 mock 对象来执行某些操作。然后,我们断言配置正在工作,并且调用了 mock。这些步骤使我们能够确定模拟是否正确设置。最小起订量仅产生测试双打。它不测试代码。您仍然需要 NUnit 这样的测试框架来测试代码。

我们现在来看一个同时使用 Moq 和 NUnit 的示例。

创建一个新的控制台应用并将其命名为CH06_Moq。添加以下接口和类-IFooBarBazUnitTests。然后,通过 Nuget 软件包管理器安装 Moq、NUnit 和 NUnit3TestAdapter。使用以下代码更新Bar类:

namespace CH06_Moq
{
    public class Bar
    {
        public virtual Baz Baz { get; set; }
        public virtual bool Submit() { return false; }
    }
}

Bar类有一个类型为Baz的虚拟属性和一个名为Submit()的虚拟方法,该方法返回一个布尔值 false。现在更新Baz类如下:

namespace CH06_Moq
{
    public class Baz
    {
        public virtual string Name { get; set; }
    }
}

Baz类有一个名为Name的字符串类型的虚拟属性。修改IFoo文件以包含以下源代码:

namespace CH06_Moq
{
    public interface IFoo
    {
        Bar Bar { get; set; }
        string Name { get; set; }
        int Value { get; set; }
        bool DoSomething(string value);
        bool DoSomething(int number, string value);
        string DoSomethingStringy(string value);
        bool TryParse(string value, out string outputValue);
        bool Submit(ref Bar bar);
        int GetCount();
        bool Add(int value);
    }
}

IFoo接口有许多属性和方法。如您所见,接口有一个对Bar类的引用,我们知道Bar类包含一个对Baz类的引用。我们现在将开始更新我们的UnitTests类,以使用 NUnit 和 Moq 测试我们新创建的接口和类。修改UnitTests类文件,使其看起来像下面的代码:

using Moq;
using NUnit.Framework;
using System;

namespace CH06_Moq
{
    [TestFixture]
    public class UnitTests
    {
    }
}

现在,添加断言是否已引发指定异常的AssertThrows方法:

public bool AssertThrows<TException>(
    Action action,
    Func<TException, bool> exceptionCondition = null
) where TException : Exception
    {
        try
        {
            action();
        }
        catch (TException ex)
        {
            if (exceptionCondition != null)
            {
                return exceptionCondition(ex);
            }
            return true;
        }
        catch
        {
            return false;
        }
        return false;
    }

AssertThrows方法是一种通用方法,如果您的方法抛出指定的异常,它将返回true,如果不抛出,则返回false。在本章进一步测试异常时,我们将使用此方法。现在,添加DoSomethingReturnsTrue()方法:

[Test]
public void DoSomethingReturnsTrue()
{
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.DoSomething("ping")).Returns(true);
    Assert.IsTrue(mock.Object.DoSomething("ping"));
}

DoSomethingReturnsTrue()方法创建IFoo接口的新模拟实现。然后设置DoSomething()方法来接受包含单词"ping"的字符串,然后返回true。最后,该方法断言,当使用文本"ping"调用DoSomething()方法时,该方法返回一个值true。现在,我们将实现一个类似的测试方法,如果值为"tracert",则返回false

[Test]
public void DoSomethingReturnsFalse()
{
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.DoSomething("tracert")).Returns(false);
    Assert.IsFalse(mock.Object.DoSomething("tracert"));
}

DoSomethingReturnsFalse()方法遵循与DoSomethingReturnsFalse()方法相同的程序。我们创建了一个IFoo接口的模拟对象,如果参数值为"tracert",则将其设置为返回false,然后断言参数值为"tracert"时返回false。接下来,我们将测试我们的参数:

[Test]
public void OutArguments()
{
    var mock = new Mock<IFoo>();
    var outString = "ack";
    mock.Setup(foo => foo.TryParse("ping", out outString)).Returns(true);
    Assert.AreEqual("ack", outString);
    Assert.IsTrue(mock.Object.TryParse("ping", out outString));
}

OutArguments()方法创建IFoo接口的实现。然后声明一个将用作输出参数的字符串,并为其赋值"ack"。接下来,设置IFoo模拟对象的TryParse()方法,以返回true输入值"ping"并输出字符串值"ack"。然后我们断言outString等于"ack"的值。最终检查确认TryParse()"ping"的输入值返回true

[Test]
public void RefArguments()
{
    var instance = new Bar();
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.Submit(ref instance)).Returns(true);
    Assert.AreEqual(true, mock.Object.Submit(ref instance));
}

RefArguments()方法创建Bar类的实例。然后,创建IFoo接口的模拟实现。如果传入的引用类型为Bar类型,则Submit()方法被设置为返回true。然后我们断言传入的参数是类型为Bartrue。在我们的AccessInvocationArguments()测试方法中,我们创建了IFoo接口的新实现:

[Test]
public void AccessInvocationArguments()
{
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.DoSomethingStringy(It.IsAny<string>()))
        .Returns((string s) => s.ToLower());
    Assert.AreEqual("i like oranges!", mock.Object.DoSomethingStringy("I LIKE ORANGES!"));
}

然后我们设置DoSomethingStringy()方法将输入转换为小写并返回。最后,我们断言返回的字符串是传入的已转换为小写的字符串:

[Test]
public void ThrowingWhenInvokedWithSpecificParameters()
{
    var mock = new Mock<IFoo>();
    mock.Setup(foo => foo.DoSomething("reset"))
        .Throws<InvalidOperationException>();
    mock.Setup(foo => foo.DoSomething(""))
        .Throws(new ArgumentException("command"));
    Assert.IsTrue(
        AssertThrows<InvalidOperationException>(
            () => mock.Object.DoSomething("reset")
        )
    );
    Assert.IsTrue(
        AssertThrows<ArgumentException>(
            () => mock.Object.DoSomething("")
        )
    );
    Assert.Throws(
        Is.TypeOf<ArgumentException>()
          .And.Message.EqualTo("command"),
          () => mock.Object.DoSomething("")
    );
 }

在我们最终的测试方法ThrowingWhenInvokedWithSpecificParameters()中,我们创建了IFoo接口的模拟实现。然后我们将DoSomething()方法配置为在传入值为"reset"时抛出InvalidOperationException

传入空字符串时,会引发"command"ArgumentException异常。然后我们断言当输入值为"reset"时抛出InvalidOperationException。当输入值为空字符串时,我们断言抛出了ArgumentException,断言ArgumentException的消息是"command"

现在,您已经了解了如何使用名为 Moq 的模拟框架创建模拟对象,以使用 NUnit 测试代码。我们现在要看的最后一个工具是SpecFlow。SpecFlow 是一个 BDD 工具。

SpecFlow

在代码之前编写的以用户为中心的行为测试是 BDD 背后的主要功能。BDD 是从 TDD 发展而来的一种软件开发方法。您可以使用功能列表启动 BDD。特性是用正式的业务语言编写的规范。项目的所有利益相关者都可以理解这种语言。一旦同意并生成了特性,开发人员就可以为特性语句开发步骤定义。创建步骤定义后,下一步是创建外部项目以实现该功能并向其添加引用。然后扩展步骤定义以实现该功能的应用代码。

这种方法的一个好处是,作为一名程序员,您可以保证交付业务所要求的内容,而不是提供您认为他们所要求的内容。这可以为企业节省大量资金和时间。过去的历史表明,许多项目失败是因为业务团队和编程团队之间不清楚需要交付什么。在开发新功能时,BDD 有助于减轻这种潜在危险。

在本章的这一节中,我们将通过使用 SpecFlow,使用 BDD 软件开发方法开发一个非常简单的计算器示例。

我们将首先编写一个功能文件,作为我们的验收标准规范。然后,我们将从生成所需方法的功能文件中生成步骤定义。一旦我们的步骤定义生成了所需的方法,我们将为它们编写代码,这样我们的功能就完成了。

创建新类库并添加以下包 NUnit、NUnit3TestAdapter、SpecFlow、SpecRun.SpecFlow 和 SpecFlow.NUnit。添加一个名为Calculator的新 SpecFlow 功能文件:

Feature: Calculator
  In order to avoid silly mistakes
  As a math idiot
  I want to be told the sum of two numbers

@mytag
Scenario: Add two numbers
  Given I have entered 50 into the calculator
  And I have entered 70 into the calculator
  When I press add
  Then the result should be 120 on the screen

前面的文本是创建时自动添加到Calculator.feature文件中的文本。因此,我们将以此作为使用 SpecFlow 学习 BDD 的起点。截至撰写本文时,值得注意的是,Tricentis已获得 SpecFlow 和 SpecMap。Tricentis 已经声明 SpecFlow、SpecFlow+和 SpecMap 都将保持免费,因此现在是学习和使用 SpecFlow 和 SpecMap 的好时机,如果您还没有这样做的话。

现在我们有了特性文件,我们需要创建步骤定义,将特性请求绑定到代码中。在代码编辑器中单击鼠标右键,将弹出关联菜单。选择“生成步骤定义”。您应该看到以下对话框:

为类名输入名称CalculatorSteps。单击 Generate 按钮生成步骤定义并保存文件。打开CalculatorSteps.cs文件,您会看到以下代码:

using TechTalk.SpecFlow;

namespace CH06_SpecFlow
{
    [Binding]
    public class CalculatorSteps
    {
        [Given(@"I have entered (.*) into the calculator")]
        public void GivenIHaveEnteredIntoTheCalculator(int p0)
        {
            ScenarioContext.Current.Pending();
        }

        [When(@"I press add")]
        public void WhenIPressAdd()
        {
            ScenarioContext.Current.Pending();
        }

        [Then(@"the result should be (.*) on the screen")]
        public void ThenTheResultShouldBeOnTheScreen(int p0)
        {
            ScenarioContext.Current.Pending();
        }
    }
}

步骤文件的内容与功能文件的比较如以下屏幕截图所示:

实现该功能的代码必须位于单独的文件中。创建一个新类库并将其命名为CH06_SpecFlow.Implementation。然后,添加一个名为Calculator.cs的文件。在 SpecFlow 项目中添加对新创建库的引用,并在CalculatorSteps.cs文件顶部添加以下行:

private Calculator _calculator = new Calculator();

我们现在可以扩展步骤定义,以便它们实现应用代码。在CalculatorSteps.cs文件中,用数字替换所有p0参数。这使得参数要求更加明确。在Calculate类的顶部,添加两个名为FirstNumberSecondNumber的公共属性,如下代码所示:

public int FirstNumber { get; set; }
public int SecondNumber { get; set; }

CalculatorSteps类中,更新GivenIHaveEnteredIntoTheCalculator()方法,如图所示:

[Given(@"I have entered (.*) into the calculator")]
public void GivenIHaveEnteredIntoTheCalculator(int number)
{
    calculator.FirstNumber = number;
}

现在,添加第二种方法GivenIHaveAlsoEnteredIntoTheCalculator(),如果它不存在,并将number参数分配给计算器的第二个数字:

public void GivenIHaveAlsoEnteredIntoTheCalculator(int number)
{
    calculator.SecondNumber = number;
}

private int result;添加到CalculatorSteps类的顶部和任何步骤之前。将Add()方法添加到Calculator类中:

public int Add()
{
    return FirstNumber + SecondNumber;
}

现在更新CalculatorSteps类中的WhenIPressAdd()方法,并用调用Add()方法的结果更新result变量:

[When(@"I press add")]
public void WhenIPressAdd()
{
    _result = _calculator.Add();
}

接下来,修改ThenTheResultShouldBeOnTheScreen()方法如下:

[Then(@"the result should be (.*) on the screen")]
public void ThenTheResultShouldBeOnTheScreen(int expectedResult)
{
    Assert.AreEqual(expectedResult, _result);
}

构建项目并运行测试。你应该看到测试通过了。只编写了功能需要通过的代码,并且您的代码通过了测试。

有关 SpecFlow 的更多信息,请访问https://specflow.org/docs/ 。我们已经介绍了一些可用于开发和测试代码的工具。现在我们来看一个简单的例子,说明如何使用 TDD 进行编码。我们将从编写失败的代码开始。然后,我们将为测试编写足够的代码进行编译。最后,我们将重构代码。

在本节中,您将学习编写失败的测试。然后,您将学习编写足以使测试通过的代码,如果必要,您将执行任何需要进行的重构。

在深入研究 TDD 的一个实际例子之前,让我们考虑一下为什么我们需要 TDD。在上一节中,您了解了如何创建功能文件并从中生成 step 文件,以编写满足业务需要的代码。确保代码满足业务需求的另一种方法是使用 TDD。使用 TDD,您将从失败的测试开始。然后,编写足够的代码使测试通过,并根据需要对新代码进行重构。重复此过程,直到所有特征编码完毕。

但是为什么我们需要 TDD?

业务软件规范由业务分析人员汇总,他们与项目涉众一起设计新软件,或对现有软件进行扩展和修改。有些软件是关键的,不能有缺陷。这类软件包括处理私人和商业投资的金融系统;医疗设备,包括关键生命支持和扫描设备,需要功能软件才能工作;交通管理和导航系统的交通信号软件;空间飞行系统;以及武器系统。

好的,但是 TDD 在哪里合适呢?

好吧,你已经得到了一个编写一个软件的规范。你需要做的第一件事就是创建你的项目。然后,为要实现的功能编写伪代码。然后继续为每段伪代码编写测试。测试失败了。然后编写使测试通过的所需代码,然后根据需要重构代码。您在这里所做的是编写经过良好测试且健壮的代码。您可以保证您的代码将按预期在隔离状态下执行。如果您的代码是更大系统的一个组件,那么测试团队的责任是测试代码的集成,而不是您。作为一名开发人员,您已经赢得了向测试团队发布代码的信心。如果测试团队确定了以前被忽略的用例,他们将与您共享它们。然后,您将编写进一步的测试并使其通过,然后再向其发布更新的代码。这样的工作方式确保了代码是最高标准的,并且可以通过给定输入的预期输出来信任代码按预期工作。最后,TDD 使软件进度可衡量,这对管理者来说是个好消息。

现在是我们演示 TDD 的时候了。在本例中,我们将使用 TDD 开发一个简单的日志应用,该应用可以处理内部异常,并将异常记录到带有时间戳的文本文件中。我们将编写程序并通过测试。一旦我们编写了程序并通过了所有测试,那么我们将重构代码以使其可重用且易于阅读,当然,我们将确保我们的测试仍然通过。

  1. 创建一个新的控制台应用并将其命名为CH06_FailPassRefactor。使用以下伪代码添加名为UnitTests的类:
using NUnit.Framework;

namespace CH06_FailPassRefactor
{
    [TestFixture]
    public class UnitTests
    {
        // The PseudoCode.
        // [1] Call a method to log an exception.
        // [2] Build up the text to log including 
        // all inner exceptions.
        // [3] Write the text to a file with a timestamp.
    }
}
  1. 我们将编写第一个单元测试以满足条件[1]。在我们的单元测试中,我们将测试创建Logger变量,调用Log()方法,并通过测试。那么,让我们编写代码:
// [1] Call a method to log an exception.
[Test]
public void LogException()
{
    var logger = new Logger();
    var logFileName = logger.Log(new ArgumentException("Argument cannot be null"));
    Assert.Pass();
}

此测试将不会运行,因为项目将不会生成。那是因为Logger类不存在。因此,在项目中添加一个名为Logger的内部类。然后运行测试。构建仍然会失败,并且测试不会运行,因为我们现在缺少Log()方法。因此,让我们将Log()方法添加到Logger类中。然后,我们将再次尝试运行测试。这一次,测试应该会成功。

  1. 在这个阶段,我们将执行任何必要的重构。但由于我们刚刚开始,没有重构要做,所以我们可以继续进行下一个测试。

我们生成日志消息并将其保存到磁盘的代码将使用私有成员。使用 NUnit,您不会测试私人成员。该学派认为,如果您必须测试私有成员,那么您的代码肯定有问题。因此,我们将继续进行下一个单元测试,它将确定日志文件是否存在。在编写单元测试之前,我们将编写一个方法,该方法返回一个包含内部异常的异常。我们将在单元测试中将返回的异常传递到Log()方法中:

private Exception GetException()
{
    return new Exception(
        "Exception: Main exception.",
        new Exception(
            "Exception: Inner Exception.",
            new Exception("Exception: Inner Exception Inner Exception")
        )
    );
}
  1. 现在,我们有了GetException()方法,可以编写单元测试来检查日志文件是否存在:
[Test]
public void CheckFileExists()
{
    var logger = new Logger();
    var logFile = logger.Log(GetException());
    FileAssert.Exists(logFile);
}
  1. 如果我们构建代码并运行CheckFileExists()测试,它将失败,因此我们需要编写代码使其成功。在Logger类中,将private StringBuilder _stringBuilder;添加到Logger类的顶部。然后修改Log()方法,将以下方法添加到Logger类中:
private StringBuilder _stringBuilder;

public string Log(Exception ex)
{
    _stringBuilder = new StringBuilder();
    return SaveLog();
}

private string SaveLog()
{
    var fileName = $"LogFile{DateTime.UtcNow.GetHashCode()}.txt";
    var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
    var file = $"{dir}\\{fileName}";
    return file;
}
  1. 我们调用了Log()方法,并生成了一个日志文件。现在,我们只需要将文本记录到文件中。根据我们的伪代码,我们需要记录主异常和所有内部异常。让我们编写一个测试,检查日志文件是否包含消息"Exception: Inner Exception Inner Exception"
[Test]
public void ContainsMessage()
{
    var logger = new Logger();
    var logFile = logger.Log(GetException());
    var msg = File.ReadAllText(logFile);
    Assert.IsTrue(msg.Contains("Exception: Inner Exception Inner Exception"));
}
  1. 现在,我们知道测试将失败,因为字符串生成器为,所以我们将把该方法添加到Logger类中,该类将接受异常,记录消息,并检查异常是否有内部异常。如果有,则会使用参数isInnerException调用自身:
private void BuildExceptionMessage(Exception ex, bool isInnerException)
{
    if (isInnerException)
        _stringBuilder.Append("Inner Exception: ").AppendLine(ex.Message);
    else
        _stringBuilder.Append("Exception: ").AppendLine(ex.Message);
    if (ex.InnerException != null)
       BuildExceptionMessage(ex.InnerException, true);
}
  1. 最后更新Logger类的Log()方法,调用我们的BuildExceptionMessage()方法:
public string Log(Exception ex)
{
    _stringBuilder = new StringBuilder();
    _stringBuilder.AppendLine("-----------------------
      -----------------");
    BuildExceptionMessage(ex, false);
    _stringBuilder.AppendLine("-----------------------
      -----------------");
    return SaveLog();
}

现在我们所有的测试都通过了,我们有了一个功能齐全的程序,可以实现预期的效果,但是这里有一个重构的机会。名为BuildExceptionMessage()的方法是重用的候选方法,因为它对于调试非常有用,特别是当您有一个内部异常的异常时,因此我们将把该方法移到它自己的方法中。请注意,Log()方法也在构建要记录的文本的开始和结束部分。

我们可以并将其转移到BuildExceptionMessage()方法中:

  1. 创建一个新类并将其命名为Text。添加一个私有StringBuilder成员变量,并在构造函数中实例化。然后,通过添加以下代码更新该类:
public string ExceptionMessage => _stringBuilder.ToString();

public void BuildExceptionMessage(Exception ex, bool isInnerException)
{
    if (isInnerException)
    {
        _stringBuilder.Append("Inner Exception: ").AppendLine(ex.Message);
    }
    else
    {
        _stringBuilder.AppendLine("--------------------------------------------------------------");
        _stringBuilder.Append("Exception: ").AppendLine(ex.Message);
    }
    if (ex.InnerException != null)
        BuildExceptionMessage(ex.InnerException, true);
    else
        _stringBuilder.AppendLine("--------------------------------------------------------------");
}
  1. 我们现在得到了一个有用的Text类,该类从包含内部异常的异常返回有用的异常消息,但我们也可以在SaveLog()方法中重构代码。我们可以将生成唯一哈希文件名的代码提取到它自己的方法中。那么,让我们在Text类中添加以下方法:
public string GetHashedTextFileName(string name, SpecialFolder folder)
{
    var fileName = $"{name}-{DateTime.UtcNow.GetHashCode()}.txt";
    var dir = Environment.GetFolderPath(folder);
    return $"{dir}\\{fileName}";
}
  1. GetHashedTextFileName()方法接受用户指定的文件名和特殊文件夹。然后在文件名末尾添加连字符和当前 UTC 日期的哈希代码。然后添加.txt文件扩展名,并将文本分配给fileName变量。调用者请求的特殊文件夹的绝对路径随后被分配给dir变量,路径和文件名随后返回给用户。此方法保证返回唯一的文件名。
  2. 用以下代码替换Logger类的主体:
        private Text _text;

        public string Log(Exception ex)
        {
            BuildMessage(ex);
            return SaveLog();
        }

        private void BuildMessage(Exception ex)
        {
            _text = new Text();
            _text.BuildExceptionMessage(ex, false);
        }

        private string SaveLog()
        {
            var filename = _text.GetHashedTextFileName("Log", 
              Environment.SpecialFolder.MyDocuments);
            File.WriteAllText(filename, _text.ExceptionMessage);
            return filename;
        }

这个类仍然在做同样的事情,但是由于消息和文件名的生成已经转移到一个单独的类中,所以它更干净、更小。如果您运行代码,它的行为方式相同。如果你运行这些测试,它们都会通过。

在本节中,我们编写了失败的单元测试,然后对其进行了修改,使其通过。然后,我们对代码进行重构,使其更干净,这导致我们编写的代码可以在同一项目或其他项目中重用。现在让我们简单地看一下冗余测试。

正如书中所说,我们对编写干净的代码感兴趣。随着程序和测试的增长,我们开始重构,一些代码将变得多余。任何冗余且未被调用的代码称为死代码。死代码一经识别,应立即删除。死代码不会在编译代码中执行,但它仍然是需要维护的代码库的一部分。带有死代码的代码文件比需要的长。除了使文件变大这一不必要的事实之外,它还可能使读取源代码变得更加困难,因为它可能会切断代码的自然流动,并给程序员读取代码增加混乱和延迟。不仅如此,任何新加入项目的程序员最不需要的就是浪费宝贵的时间去理解那些永远不会被使用的死代码。所以最好是摆脱它。

至于注释,如果操作得当,它们会非常有用,而 API 注释对于 API 文档的生成尤其有益。但是,有些注释只是给代码文件添加了一些噪音,令人惊讶的是,许多程序员可能会对它们感到恼火。有一组程序员会对任何事情发表评论。另一个小组不会对任何事情发表评论,因为他们认为代码应该读起来像一本书。还有一些人采取平衡的方法,只在人们认为有必要理解代码时才对代码进行评论。

当你看到这样的评论-“这经常会产生一个随机错误。不知道为什么。但是欢迎你来修复它!”-警钟应该开始响了。首先,编写注释的程序员应该坚持使用代码,在确定产生错误的条件之前不要继续,然后错误应该得到修复。如果您知道编写注释的程序员是谁,那么将代码返回给他们以修复和删除注释。我不止一次看到过这样的代码,我在网上看到过一些评论,表达了对这些评论的强烈看法。我想这是一种对付懒惰程序员的方法。如果他们不懒惰,而只是缺乏经验,那么在问题诊断和解决方面,这是一项很好的学习任务。

如果代码已签入并获得批准,并且您遇到已注释掉的代码块,则将其删除。代码仍将存在于版本控制历史记录中,如果必须,您可以从中检索代码。

代码应该像书一样阅读,所以你不应该仅仅为了好看和给同事留下深刻印象而让代码变得晦涩难懂,因为我保证,当你在几周后回到自己的代码时,你会挠头想你自己的代码是什么,为什么。我见过很多大三学生犯这种错误。

还应删除冗余测试。您只需要运行必要的测试。对冗余代码的测试没有价值,可能会浪费大量时间。此外,如果您的公司有也在云中运行测试的 CI/CD 管道,那么冗余测试和死代码会增加构建、测试和部署管道的业务成本。这意味着您上传、构建、测试和部署的代码行越少,您的公司就越不需要支付运行成本。请记住,在云中运行流程需要花钱,而企业的目标是尽可能少花钱,但要赚大钱。

现在我们已经完成了这一章,让我们总结一下我们学到的东西。

我们从研究开发人员编写单元测试来开发有质量保证的代码的重要性开始。确定了可能由软件中的错误引起的理论问题。这些包括生命损失和昂贵的诉讼。然后讨论了单元测试以及什么是好的单元测试。我们发现一个好的单元测试必须是原子的、确定性的、可重复的和快速的。

接下来,我们继续研究开发人员可以使用的帮助 TDD 和 BDD 的工具。讨论了 MSTest 和 NUnit,并举例说明了如何实现 TDD。然后,我们研究了如何结合 NUnit 使用名为 Moq 的模拟框架来测试模拟对象。我们对工具的研究以 SpecFlow 结束,SpecFlow 是一种 BDD 工具,它允许我们用技术人员和非技术人员都能理解的商业语言编写功能,以确保企业想要的是企业得到的。

然后,NUnit 开始工作,我们使用fail、pass 和 refactor方法完成了一个非常简单的 TDD 示例,最后研究了为什么要删除不必要的注释、冗余测试和死代码。

在本章末尾,您将找到有关测试软件程序的更多资源。在下一章中,我们将介绍端到端测试。但在此之前,您不妨尝试一下以下问题,看看您保留了多少关于单元测试的知识。

  1. 什么是好的单元测试?
  2. 好的单元测试不应该是什么?
  3. TDD 代表什么?
  4. BDD 代表什么?
  5. 什么是单元测试?
  6. 什么是模拟对象?
  7. 什么是赝品?
  8. 列举一些单元测试框架。
  9. 列举一些模拟框架。
  10. 命名一个 BDD 框架。
  11. 应该从源代码文件中删除什么?

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

深入浅出gRPC -〔李林锋〕

Linux实战技能100讲 -〔尹会生〕

浏览器工作原理与实践 -〔李兵〕

Netty源码剖析与实战 -〔傅健〕

Serverless入门课 -〔蒲松洋(秦粤)〕

编译原理实战课 -〔宫文学〕

陶辉的网络协议集训班02期 -〔陶辉〕

Dubbo源码剖析与实战 -〔何辉〕

B端产品经理入门课 -〔董小圣〕