单元测试,如何编写可测试代码及其重要性

已发表: 2022-03-11

单元测试是任何认真的软件开发人员工具箱中必不可少的工具。 但是,有时很难为特定的代码段编写好的单元测试。 由于难以测试自己或他人的代码,开发人员通常认为他们的困难是由于缺乏一些基本的测试知识或秘密的单元测试技术。

在本单元测试教程中,我打算证明单元测试非常简单; 使单元测试复杂化并引入昂贵的复杂性的真正问题是设计不良、无法测试的代码的结果。 我们将讨论是什么使代码难以测试,我们应该避免哪些反模式和不良实践以提高可测试性,以及通过编写可测试代码可以获得哪些其他好处。 我们将看到编写单元测试和生成可测试的代码不仅仅是为了减少测试的麻烦,而是为了让代码本身更健壮,更容易维护。

单元测试教程:封面图

什么是单元测试?

本质上,单元测试是一种实例化我们应用程序的一小部分并独立于其他部分验证其行为的方法。 一个典型的单元测试包含 3 个阶段:首先,它初始化它想要测试的应用程序的一小部分(也称为被测系统,或 SUT),然后它对被测系统施加一些刺激(通常通过调用方法),最后,它观察结果行为。 如果观察到的行为与预期一致,则单元测试通过,否则,单元测试失败,表明被测系统某处存在问题。 这三个单元测试阶段也称为 Arrange、Act 和 Assert,或简称为 AAA。

单元测试可以验证被测系统的不同行为方面,但它很可能属于以下两类之一:基于状态的或基于交互的。 验证被测系统是否产生正确的结果,或者其结果状态是否正确,称为基于状态的单元测试,而验证它是否正确调用某些方法称为基于交互的单元测试。

作为适当的软件单元测试的隐喻,想象一个疯狂的科学家想要构建一些超自然的嵌合体,有青蛙腿、章鱼触手、鸟翅膀和狗头。 (这个比喻非常接近程序员在工作中的实际工作)。 那位科学家如何确保他挑选的每个零件(或单元)都真正起作用? 好吧,比方说,他可以拿一只青蛙的腿,对其施加电刺激,然后检查肌肉是否收缩。 他所做的基本上与单元测试的 Arrange-Act-Assert 步骤相同; 唯一的区别是,在这种情况下, unit指的是一个物理对象,而不是我们构建程序的抽象对象。

什么是单元测试:插图

我将在本文中的所有示例中使用 C#,但所描述的概念适用于所有面向对象的编程语言。

一个简单的单元测试示例如下所示:

 [TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }

单元测试与集成测试

另一个需要考虑的重要事情是单元测试和集成测试之间的区别。

软件工程中单元测试的目的是验证一个相对较小的软件的行为,独立于其他部分。 单元测试的范围很窄,允许我们覆盖所有情况,确保每个部分都能正常工作。

另一方面,集成测试表明系统的不同部分在现实环境中协同工作。 它们验证复杂的场景(我们可以将集成测试视为用户在我们的系统中执行一些高级操作),并且通常需要存在外部资源,例如数据库或 Web 服务器。

让我们回到我们疯狂科学家的比喻,假设他已经成功地结合了嵌合体的所有部分。 他想对生成的生物进行集成测试,以确保它可以在不同类型的地形上行走。 首先,科学家必须模拟一个生物可以行走的环境。 然后,他将生物扔到那个环境中,并用一根棍子戳它,观察它是否按照设计的方式行走和移动。 完成一项测试后,这位疯狂的科学家清理了散落在他可爱实验室中的所有泥土、沙子和岩石。

单元测试示例图

请注意单元测试和集成测试之间的显着区别:单元测试验证应用程序的一小部分的行为,与环境和其他部分隔离,并且非常容易实现,而集成测试涵盖不同组件之间的交互,在接近真实的环境,并且需要更多的努力,包括额外的设置和拆卸阶段。

单元测试和集成测试的合理组合可确保每个单元独立于其他单元正常工作,并且所有这些单元在集成时都能很好地发挥作用,使我们对整个系统按预期工作充满信心。

但是,我们必须记住始终确定我们正在实施哪种测试:单元测试或集成测试。 这种差异有时可能具有欺骗性。 如果我们认为我们正在编写一个单元测试来验证业务逻辑类中的一些微妙的边缘案例,并意识到它需要存在 Web 服务或数据库等外部资源,那是不对的——本质上,我们正在使用大锤咬紧牙关。 这意味着糟糕的设计。

什么是好的单元测试?

在深入本教程的主要部分和编写单元测试之前,让我们快速讨论一个好的单元测试的属性。 单元测试原则要求一个好的测试是:

  • 容易写。 开发人员通常会编写大量单元测试来涵盖应用程序行为的不同情况和方面,因此应该很容易对所有这些测试例程进行编码,而无需付出巨大的努力。

  • 可读。 单元测试的意图应该是明确的。 一个好的单元测试讲述了我们应用程序的某些行为方面的故事,因此应该很容易理解正在测试哪个场景,并且如果测试失败,很容易检测到如何解决问题。 通过良好的单元测试,我们可以在不实际调试代码的情况下修复错误!

  • 可靠的。 只有在被测系统中存在错误时,单元测试才会失败。 这似乎很明显,但是即使没有引入错误,程序员也经常在测试失败时遇到问题。 例如,测试可能在一个接一个地运行时通过,但在运行整个测试套件时失败,或者在我们的开发机器上通过并在持续集成服务器上失败。 这些情况表明存在设计缺陷。 好的单元测试应该是可重现的,并且独立于外部因素,例如环境或运行顺序。

  • 快速地。 开发人员编写单元测试,以便他们可以重复运行它们并检查是否没有引入任何错误。 如果单元测试很慢,开发人员更有可能跳过在自己的机器上运行它们。 一个缓慢的测试不会产生重大影响; 再加一千,我们肯定会等一会儿。 缓慢的单元测试也可能表明被测系统或测试本身与外部系统交互,使其依赖于环境。

  • 真正的单元,而不是集成。 正如我们已经讨论过的,单元测试和集成测试有不同的目的。 单元测试和被测系统都不应访问网络资源、数据库、文件系统等,以排除外部因素的影响。

就是这样——编写单元测试没有什么秘密。 但是,有一些技术可以让我们编写可测试的代码

可测试和不可测试的代码

有些代码的编写方式很难,甚至不可能为它编写一个好的单元测试。 那么,是什么让代码难以测试呢? 让我们回顾一下在编写可测试代码时应该避免的一些反模式、代码异味和不良做法。

用非确定性因素毒害代码库

让我们从一个简单的例子开始。 想象一下,我们正在为智能家居微控制器编写程序,其中一个要求是如果在晚上或晚上检测到后院有一些动作,则自动打开后院的灯。 我们从自下而上开始,实现了一个方法,该方法返回一个表示一天中大致时间(“Night”、“Morning”、“Afternoon”或“Evening”)的字符串:

 public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }

本质上,此方法读取当前系统时间并根据该值返回结果。 那么,这段代码有什么问题呢?

如果我们从单元测试的角度考虑它,我们会发现不可能为这种方法编写适当的基于状态的单元测试。 DateTime.Now本质上是一个隐藏的输入,它可能会在程序执行期间或测试运行之间发生变化。 因此,对它的后续调用将产生不同的结果。

这种不确定的行为使得在不实际更改系统日期和时间的情况下无法测试GetTimeOfDay()方法的内部逻辑。 让我们看看需要如何实现这样的测试:

 [TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }

像这样的测试会违反前面讨论的许多规则。 编写起来会很昂贵(由于非平凡的设置和拆卸逻辑),不可靠(例如,由于系统权限问题,即使被测系统中没有错误,它也可能失败),并且不能保证快跑。 最后,这个测试实际上不会是单元测试——它会介于单元测试和集成测试之间,因为它假装测试一个简单的边缘案例,但需要以特定方式设置环境。 结果不值得努力,对吧?

事实证明,所有这些可测试性问题都是由低质量的GetTimeOfDay() API 引起的。 在目前的形式中,这种方法存在几个问题:

  • 它与具体的数据源紧密耦合。 无法重用此方法来处理从其他来源检索或作为参数传递的日期和时间; 该方法仅适用于执行代码的特定机器的日期和时间。 紧耦合是大多数可测试性问题的根源。

  • 它违反了单一职责原则(SRP)。 该方法具有多重职责; 它消耗信息并对其进行处理。 SRP 违规的另一个指标是单个类或方法有多个更改理由。 从这个角度来看, GetTimeOfDay()方法可能会因为内部逻辑调整而改变,或者因为日期和时间源应该改变。

  • 它在于完成工作所需的信息。 开发人员必须阅读实际源代码的每一行,以了解使用了哪些隐藏输入以及它们来自何处。 仅方法签名不足以理解方法的行为。

  • 很难预测和维护。 仅通过阅读源代码无法预测依赖于可变全局状态的方法的行为; 有必要考虑它的当前值,以及可能更早改变它的整个事件序列。 在现实世界的应用程序中,试图解开所有这些东西变得非常头疼。

在审查了 API 之后,让我们最终修复它! 幸运的是,这比讨论它的所有缺陷要容易得多——我们只需要打破紧密耦合的关注点。

修复 API:引入方法参数

修复 API 最明显和最简单的方法是引入方法参数:

 public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }

现在该方法需要调用者提供一个DateTime参数,而不是自己偷偷地寻找这个信息。 从单元测试的角度来看,这很棒; 该方法现在是确定性的(即,它的返回值完全取决于输入),因此基于状态的测试就像传递一些DateTime值并检查结果一样简单:

 [TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }

请注意,这个简单的重构还解决了前面讨论的所有 API 问题(紧密耦合、违反 SRP、不清楚和难以理解的 API),方法是在应该处理的数据和应该如何处理的数据之间引入清晰的接缝。

非常好——方法是可测试的,但是它的客户呢? 现在调用者有责任为GetTimeOfDay(DateTime dateTime)方法提供日期和时间,这意味着如果我们不给予足够的关注,它们可能会变得无法测试。 让我们看看我们如何处理它。

修复客户端 API:依赖注入

假设我们继续在智能家居系统上工作,并实现GetTimeOfDay(DateTime dateTime)方法的以下客户端——上述智能家居微控制器代码负责根据一天中的时间和运动检测来打开或关闭灯:

 public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }

哎哟! 我们有相同类型的隐藏DateTime.Now输入问题——唯一的区别是它位于更高的抽象级别。 为了解决这个问题,我们可以引入另一个论点,再次将提供DateTime值的责任委托给具有签名ActuateLights(bool motionDetected, DateTime dateTime)的新方法的调用者。 但是,与其在调用堆栈中再次将问题移至更高级别,不如让我们使用另一种技术,使我们能够保持ActuateLights(bool motionDetected)方法及其客户端可测试:控制反转,或 IoC。

控制反转是一种简单但非常有用的技术,用于解耦代码,尤其是用于单元测试。 (毕竟,保持松散耦合对于能够相互独立地分析它们至关重要。)IoC 的关键是将决策代码(何时做某事)与行动代码(发生某事时该怎么做)分开)。 这种技术增加了灵活性,使我们的代码更加模块化,并减少了组件之间的耦合。

控制反转可以通过多种方式实现; 让我们看一个特定的例子——使用构造函数的依赖注入——以及它如何帮助构建可测试的SmartHomeController API。

首先,让我们创建一个IDateTimeProvider接口,其中包含用于获取某些日期和时间的方法签名:

 public interface IDateTimeProvider { DateTime GetDateTime(); }

然后,让SmartHomeController引用一个IDateTimeProvider实现,并委托它获取日期和时间:

 public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }

现在我们可以看到为什么叫 Inversion of Control 了:使用什么机制读取日期和时间的控制权反转了,现在属于SmartHomeController客户端,而不是SmartHomeController本身。 因此, ActuateLights(bool motionDetected)方法的执行完全取决于可以从外部轻松管理的两件事: motionDetected参数和IDateTimeProvider的具体实现,传递给SmartHomeController构造函数。

为什么这对单元测试很重要? 这意味着可以在生产代码和单元测试代码中使用不同IDateTimeProvider实现。 在生产环境中,将注入一些真实的实现(例如,读取实际系统时间的实现)。 然而,在单元测试中,我们可以注入一个“假”实现,它返回一个常量或预定义的DateTime值,适合测试特定场景。

IDateTimeProvider的假实现可能如下所示:

 public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }

在此类的帮助下,可以将SmartHomeController与非确定性因素隔离开来,并执行基于状态的单元测试。 让我们验证一下,如果检测到运动,则该运动的时间记录在LastMotionTime属性中:

 [TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }

伟大的! 在重构之前不可能进行这样的测试。 现在我们已经消除了非确定性因素并验证了基于状态的场景,您认为SmartHomeController是否完全可测试?

用副作用毒害代码库

尽管我们解决了由非确定性隐藏输入引起的问题,并且我们能够测试某些功能,但代码(或至少其中一些)仍然无法测试!

让我们回顾一下负责打开或关闭灯的ActuateLights(bool motionDetected)方法的以下部分:

 // If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }

正如我们所见, SmartHomeController将打开或关闭灯的职责委托给了BackyardLightSwitcher对象,该对象实现了单例模式。 这个设计有什么问题?

要对ActuateLights(bool motionDetected)方法进行完整的单元测试,除了基于状态的测试外,我们还应该执行基于交互的测试; 也就是说,我们应该确保当且仅当满足适当的条件时才调用打开或关闭灯的方法。 不幸的是,当前的设计不允许我们这样做: BackyardLightSwitcherTurnOn()TurnOff()方法会触发系统中的某些状态更改,或者换句话说,会产生副作用。 验证这些方法是否被调用的唯一方法是检查它们相应的副作用是否确实发生了,这可能会很痛苦。

事实上,让我们假设运动传感器、后院灯和智能家居微控制器连接到物联网网络并使用某种无线协议进行通信。 在这种情况下,单元测试可以尝试接收和分析该网络流量。 或者,如果硬件组件用电线连接,单元测试可以检查电压是否施加到适当的电路上。 或者,毕竟,它可以使用额外的光传感器检查灯是否实际打开或关闭。

正如我们所看到的,单元测试副作用方法可能与单元测试非确定性方法一样困难,甚至可能是不可能的。 任何尝试都会导致类似于我们已经看到的问题。 由此产生的测试将难以实施、不可靠、可能很慢并且不是真正的单元。 而且,毕竟,每次我们运行测试套件时闪烁的灯光最终会让我们发疯!

同样,所有这些可测试性问题都是由糟糕的 API 引起的,而不是开发人员编写单元测试的能力。 无论灯光控制是如何实现的, SmartHomeController API 都会遇到这些已经很熟悉的问题:

  • 它与具体实现紧密耦合。 API 依赖于BackyardLightSwitcher的硬编码具体实例。 不能重用ActuateLights(bool motionDetected)方法来切换除后院的灯之外的任何灯。

  • 它违反了单一职责原则。 API的变化有两个原因:一是内部逻辑的变化(比如选择只在晚上开灯,晚上不开灯),二是如果换了一个开关灯机制。

  • 它在于它的依赖关系。 开发者没有办法知道SmartHomeController依赖于硬编码的BackyardLightSwitcher组件,只能深入研究源代码。

  • 很难理解和维护。 如果条件合适时灯拒绝打开怎么办? 我们可能会花费大量时间尝试修复SmartHomeController无济于事,结果却发现问题是由BackyardLightSwitcher中的错误引起的(或者,更有趣的是,灯泡烧坏了!)。

毫不奇怪,可测试性和低质量 API 问题的解决方案是将紧密耦合的组件彼此分开。 和前面的例子一样,使用依赖注入可以解决这些问题; 只需将ILightSwitcher依赖项添加到SmartHomeController ,委托它翻转电灯开关的职责,并传递一个虚假的、仅用于测试的ILightSwitcher实现,该实现将记录是否在正确的条件下调用了适当的方法。 然而,与其再次使用依赖注入,不如让我们回顾一个有趣的替代方法来解耦职责。

修复 API:高阶函数

在任何支持一流函数的面向对象语言中,这种方法都是一种选择。 让我们利用 C# 的功能特性,让ActuateLights(bool motionDetected)方法接受另外两个参数:一对Action委托,指向应该调用以打开和关闭灯的方法。 此解决方案会将方法转换为高阶函数

 public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }

这是一个比我们之前看到的经典的面向对象的依赖注入方法更具功能性的解决方案; 但是,与依赖注入相比,它可以让我们用更少的代码和更多的表现力来实现相同的结果。 不再需要实现符合接口的类来为SmartHomeController提供所需的功能; 相反,我们可以只传递一个函数定义。 高阶函数可以被认为是实现控制反转的另一种方式。

现在,要对生成的方法执行基于交互的单元测试,我们可以将易于验证的虚假操作传递给它:

 [TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }

最后,我们使SmartHomeController API 完全可测试,并且我们能够为它执行基于状态和基于交互的单元测试。 再次注意,除了提高可测试性之外,在决策和操作代码之间引入接缝有助于解决紧密耦合问题,并产生更清洁、可重用的 API。

现在,为了实现完整的单元测试覆盖,我们可以简单地实现一堆看起来相似的测试来验证所有可能的情况——这没什么大不了的,因为单元测试现在很容易实现。

杂质和可测试性

不受控制的不确定性和副作用在对代码库的破坏性影响方面是相似的。 如果使用不慎,它们会导致具有欺骗性、难以理解和维护、紧密耦合、不可重用和不可测试的代码。

另一方面,确定性无副作用的方法更容易测试、推理和重用以构建更大的程序。 在函数式编程方面,这种方法称为纯函数。 我们很少会在单元测试纯函数时遇到问题; 我们所要做的就是传递一些参数并检查结果的正确性。 真正使代码不可测试的是硬编码的、不纯的因素,这些因素不能被替换、覆盖或以其他方式抽象掉。

杂质是有毒的:如果方法Foo()依赖于非确定性或副作用方法Bar() ,那么Foo()也变得非确定性或副作用。 最终,我们可能最终会毒害整个代码库。 将所有这些问题乘以一个复杂的现实应用程序的大小,我们会发现自己被一个难以维护的代码库所困扰,其中充满了异味、反模式、秘密依赖以及各种丑陋和令人不快的事情。

单元测试示例:插图

然而,杂质是不可避免的; 任何现实生活中的应用程序都必须在某些时候通过与环境、数据库、配置文件、Web 服务或其他外部系统交互来读取和操作状态。 因此,与其完全消除杂质,不如限制这些因素,避免它们毒害您的代码库,并尽可能打破硬编码的依赖关系,以便能够独立地分析和单元测试事物。

难以测试代码的常见警告信号

编写测试有问题? 问题不在您的测试套件中。 它在你的代码中。
鸣叫

最后,让我们回顾一些常见的警告标志,这些标志表明我们的代码可能难以测试。

静态属性和字段

静态属性和字段,或者简单地说,全局状态,可以通过隐藏方法完成工作所需的信息、引入非确定性或促进副作用的广泛使用,使代码理解和可测试性复杂化。 读取或修改可变全局状态的函数本质上是不纯的。

例如,很难推断以下代码,它依赖于全局可访问的属性:

 if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }

如果在我们确定应该调用HeatWater()方法时没有调用它怎么办? 由于应用程序的任何部分都可能更改了CostSavingEnabled值,因此我们必须找到并分析所有修改该值的位置,以找出问题所在。 此外,正如我们已经看到的那样,不可能为测试目的设置一些静态属性(例如DateTime.NowEnvironment.MachineName ;它们是只读的,但仍然是非确定性的)。

另一方面,不可变确定性的全局状态是完全可以的。 事实上,有一个更熟悉的名称——常数。 像Math.PI这样的常量值不会引入任何不确定性,并且由于它们的值无法更改,因此不允许任何副作用:

 double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!

单身人士

本质上,单例模式只是全局状态的另一种形式。 单例促进了隐藏在真正依赖关系上的 API,并在组件之间引入了不必要的紧密耦合。 他们还违反了单一职责原则,因为除了他们的主要职责之外,他们还控制着自己的初始化和生命周期。

单例可以很容易地使单元测试依赖于顺序,因为它们在整个应用程序或单元测试套件的生命周期中携带状态。 看看下面的例子:

 User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }

在上面的示例中,如果缓存命中场景的测试首先运行,它将向缓存添加新用户,因此缓存未命中场景的后续测试可能会失败,因为它假定缓存为空。 为了克服这个问题,我们必须编写额外的拆卸代码来在每次单元测试运行后清理UserCache

在大多数情况下,可以(并且应该)避免使用 Singletons 是一种不好的做法; 但是,区分作为设计模式的 Singleton 和对象的单个实例是很重要的。 在后一种情况下,创建和维护单个实例的责任在于应用程序本身。 通常,这是由工厂或依赖注入容器处理的,它在应用程序“顶部”附近的某个地方(即更靠近应用程序入口点)创建一个实例,然后将其传递给每个需要它的对象。 从可测试性和 API 质量的角度来看,这种方法是绝对正确的。

new运营商

为完成某些工作而新建一个对象实例会引入与 Singleton 反模式相同的问题:具有隐藏依赖项、紧密耦合和可测试性差的不清楚的 API。

例如,为了测试以下循环是否在返回 404 状态码时停止,开发者应该设置一个测试 Web 服务器:

 using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }

然而,有时new是绝对无害的:例如,创建简单的实体对象是可以的:

 var person = new Person("John", "Doe", new DateTime(1970, 12, 31));

也可以创建一个不会产生任何副作用的小型临时对象,除了修改自己的状态,然后根据该状态返回结果。 在下面的例子中,我们不关心Stack方法是否被调用——我们只检查最终结果是否正确:

 string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }

静态方法

静态方法是非确定性或副作用行为的另一个潜在来源。 它们可以很容易地引入紧耦合并使我们的代码无法测试。

例如,要验证以下方法的行为,单元测试必须操作环境变量并读取控制台输出流以确保打印了适当的数据:

 void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }

但是,静态函数是可以的:它们的任何组合仍然是纯函数。 例如:

 double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

单元测试的好处

显然,编写可测试的代码需要一定的纪律、专注和额外的努力。 但无论如何,软件开发是一项复杂的脑力活动,我们应该始终小心谨慎,避免从头顶胡乱拼凑新代码。

作为对这种适当的软件质量保证行为的奖励,我们最终将获得干净、易于维护、松散耦合和可重用的 API,这些 API 在开发人员试图理解它时不会损害他们的大脑。 毕竟,可测试代码的最终优势不仅在于可测试性本身,还在于易于理解、维护和扩展该代码的能力。