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

原文来自互联网,由长沙DotNET技术社区编译。如译文侵犯您的署名权或版权,请联系小编,小编将在24小时内删除。限于译者的能力有限,个别语句翻译略显生硬,还请见谅。

作者:谢尔盖·科洛迪(SERGEY KOLODIY)

原文:https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters

单元测试是任何专业软件开发人员工具箱中必不可少的工具,但是,有时很难为特定的代码编写好的单元测试。开发人员在测试自己或他人的代码时遇到困难,常常认为自己的努力是由于缺乏一些基本的测试知识或以为单元测试存在某些独门绝技而引起的。

在本单元测试教程中,我打算证明单元测试非常简单。真正的问题是复杂的单元测试,并引进昂贵的复杂性,以及设计拙劣的,不可测试的代码。我们将讨论什么使得代码难以测试,应避免哪些反模式和不良实践以提高可测试性,以及通过编写可测试的代码可以带来哪些其他好处。我们将看到编写单元测试和生成可测试的代码不仅是要减少测试的麻烦,还在于使代码本身更健壮和易于维护。

图片

什么是单元测试?

本质上,单元测试是一种我们应用程序一小部分能够独立于其他部分来实例化并验证其行为的方法。典型的单元测试包含三个阶段:

首先,它初始化要测试的应用程序的一小部分(也称为被测系统,即SUT),然后对被测系统施加一些刺激(通常通过调用方法),最后观察结果。

如果观察到的行为符合预期,则单元测试通过,否则,它将失败,表明被测系统中的某处存在问题。这三个单元测试阶段也称为“安排”(Arrange),“行为”(Act)和“断言”(Assest),或简称为AAA。

单元测试可以验证被测系统的不同行为方面,但很可能会属于以下两类之一:

基于状态

基于交互

。验证被测系统产生正确的结果或结果状态正确的方法称为基于状态的单元测试,而验证其正确调用某些方法的方法称为基于交互的单元测试。

作为适当的软件单元测试的比喻,想象一下一个疯狂的科学家,他想用青蛙腿,章鱼触角,鸟翅膀和狗的头来构建一些超自然的嵌合体[1]。(这个比喻与程序员在工作中实际所做的非常接近)。那位科学家将如何确保他挑选的每个零件(或单元)都能正常工作?好吧,比方说,他可以握住一只青蛙的腿,对其施加电刺激,并检查肌肉是否适当收缩。他所做的基本上与单元测试中的“Arrange-Act-Assert”步骤相同。唯一的区别是,在这种情况下,

单位

是指物理对象,而不是我们用来构建程序的抽象对象。

图片

我将在本文的所有示例中使用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服务或数据库之类的外部资源,那是不对的-

本质上讲,我们使用大锤来坚果。这意味着设计不好。

什么是好的单元测试?

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

容易写。开发人员通常编写大量的单元测试以涵盖应用程序行为的不同情况和方面,因此应该容易地编写所有这些测试例程,而无需付出很大的努力。•可读。单元测试的目的应该明确。好的单元测试可以讲述我们应用程序某些行为方面的故事,因此,应该很容易理解正在测试的场景,并且-如果测试失败-则很容易检测到如何解决问题。通过良好的单元测试,我们可以在不实际调试代码的情况下修复错误!•可靠。仅当被测系统中存在错误时,单元测试才会失败。这似乎很明显,但是即使没有引入错误,程序员在测试失败时也会遇到问题。例如,测试可能会在一次运行时通过,但在运行整个测试套件时会失败,或者在我们的开发机器上通过而在连续集成服务器上会失败。这些情况表明存在设计缺陷。好的单元测试应具有可重复性,并且应不受外界因素(例如环境或运行顺序)的影响。•快速。开发人员编写单元测试,以便他们可以重复运行它们并检查是否未引入错误。如果单元测试很慢,则开发人员更有可能跳过在自己的计算机上运行它们的过程。一项缓慢的测试不会产生重大变化。再增加一千,我们肯定会等待一段时间。缓慢的单元测试还可能表明被测系统或测试本身与外部系统交互,从而使其与环境有关。•真正的单元,而不是集成。正如我们已经讨论过的,单元测试和集成测试具有不同的目的。单元测试和被测系统都不应访问网络资源,数据库,文件系统等,以消除外部因素的影响。

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

可测试和不可测试的代码

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

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

让我们从一个简单的例子开始。想象一下,我们正在编写一个用于智能家居微控制器的程序,其中一项要求是,如果在晚上或晚上发现后院有运动,则自动打开后院的灯。我们从头开始,通过实现一种方法来返回大约一天中的时间(“夜晚”,“早晨”,“下午”或“晚上”)的字符串表示:

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// Actstring timeOfDay = GetTimeOfDay();// AssertAssert.AreEqual("Morning", timeOfDay);}finally{// Teardown: roll system time back...}
}

这样的测试将违反前面讨论的许多规则。编写(由于设置和拆卸逻辑很简单)昂贵,不可靠(例如,由于系统权限问题,即使被测系统中没有错误,它也可能会失败),并且不能保证小步快跑。最后,该测试实际上并不是单元测试,而是介于单元测试和集成测试之间的,因为它假装测试简单的边缘情况,但需要以特定方式设置环境。结果不值得付出努力,是吗?事实证明,所有这些可测试性问题都是由低质量的GetTimeOfDay() 引起的。在当前形式下,此方法存在几个问题:

它与具体的数据源紧密耦合。无法重用此方法来处理从其他来源检索或作为参数传递的日期和时间。该方法仅适用于执行代码的特定计算机的日期和时间。紧密耦合是大多数可测试性问题的根源。•它违反了单一责任原则[2](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// Actstring timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00));// AssertAssert.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)方法和其客户端保持可测试状态的另一种方法,而不是将问题再次移到调用堆栈中的更高级别:Control Inversion[3]或IoC。控制反转是一种用于将代码解耦,尤其是用于单元测试的简单但极为有用的技术。(毕竟,让事情松耦合是能够独立地分析它们彼此是必不可少的。)IOC的重点是独立决策的代码(在做一些事情)的动作代码(什么到有事时做)。这种技术增加了灵活性,使我们的代码更具模块化,并减少了组件之间的耦合。

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

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

public interface IDateTimeProvider
{DateTime GetDateTime();
}

然后,SmartHomeController引用一个IDateTimeProvider实现,并将其委派给获取日期和时间的责任:

public class SmartHomeController
{private readonly IDateTimeProvider _dateTimeProvider; // Dependencypublic 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...}
}

现在我们可以看到,为什么控制反转是所谓的:将控制到什么机构使用的读取日期和时间倒转,现在是属于客户的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()
{// Arrangevar controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59)));// Actcontroller.ActuateLights(true);// AssertAssert.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实现了Singleton模式的对象。这种设计有什么问题?为了对ActuateLights(bool motionDetected)方法进行完全的单元测试,除了基于状态的测试之外,我们还应该执行基于交互的测试;也就是说,我们应该确保只有在满足适当条件的情况下,才能调用打开或关闭灯的方法。不幸的是,目前的设计不允许我们这样做:在TurnOn()与TurnOff()方法BackyardLightSwitcher触发一些状态的变化在系统中,或者,换句话说,产生的副作用。验证是否调用了这些方法的唯一方法是检查它们相应的副作用是否确实发生了,这可能会很痛苦。

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

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

同样,所有这些可测试性问题都是由不良的API引起的,而不是由开发人员编写单元测试的能力引起的。无论如何实现精确的灯光控制,SmartHomeControllerAPI都将面临以下这些已熟悉的问题:

它与具体实现紧耦合。该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)));// Actcontroller.ActuateLights(true, turnOn, turnOff);// AssertAssert.IsTrue(turnedOn);
}

最后,我们已经使该SmartHomeControllerAPI完全可测试,并且能够对其执行基于状态和基于交互的单元测试。同样,请注意,除了提高可测试性之外,在决策和操作代码之间引入接缝还有助于解决紧密耦合问题,并导致了更干净,可重用的API。现在,为了实现完整的单元测试覆盖范围,我们可以简单地实现一堆外观相似的测试来验证所有可能的情况,这没什么大不了的,因为单元测试现在很容易实现。

杂质和可测性

不受控制的不确定性和副作用在代码库上的破坏性作用相似。如果使用不当,它们会导致欺骗性,难以理解和维护的代码,紧密耦合,不可重用和不可测试的代码。

不受控制的不确定性和副作用在代码库上的破坏性作用相似。如果使用不当,它们会导致欺骗性,难以理解和维护的代码,紧密耦合,不可重用和不可测试的代码。

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

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

图片

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

难以测试的常见警告标志

编写测试麻烦吗?问题不在您的测试套件中。它在您的代码中。

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

静态属性和字段

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

例如,很难推理以下代码,这取决于全局可访问的属性:

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

另一方面,不变

确定的全局状态完全可以。实际上,对此有一个更熟悉的名称-常量。常量值Math.PI不会引入任何不确定性,并且由于不能更改其值,因此不会产生任何副作用:

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

Singletons

本质上,单例模式只是全局状态的另一种形式。单例促进了模糊的API,这些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不会损害开发人员的理解能力。毕竟,可测试代码的最终优势不仅在于可测试性本身,还在于创造易于理解的代码,提供维护和扩展该代码的能力。

References

[1] 嵌合体: https://en.wikipedia.org/wiki/Chimera_(mythology)
[2] 单一责任原则: https://en.wikipedia.org/wiki/Single_responsibility_principle
[3] Control Inversion: https://en.wikipedia.org/wiki/Inversion_of_control
[4] 依赖注入: https://en.wikipedia.org/wiki/Dependency_injection

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/310676.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

C++ class实现孩子表示法

代码如下: #include <iostream> using namespace std; typedef char ElemType; const int MAXSIZE 100;class link {friend class Node; private:int child;link *next; };class Node {friend class Tree; private:ElemType data;link *first; };class Tree { private:…

IdentityServer 部署踩坑记

IdentityServer 部署踩坑记Intro周末终于部署了 IdentityServer 以及 IdentityServerAdmin 项目&#xff0c;踩了几个坑&#xff0c;在此记录分享一下。部署架构项目是基于 IdentityServerAdmin 项目修改的&#xff0c;感谢作者的开源付出&#xff0c;有需要 IdentityServer 管…

.Net Core微服务架构技术栈的那些事

一、前言大家一直都在谈论微服务架构&#xff0c;园子里面也有很多关于微服务的文章&#xff0c;前几天也有一些园子的朋友问我微服务架构的一些技术&#xff0c;我这里就整理了微服务架构的技术栈路线图&#xff0c;这里就分享出来和大家一起探讨学习&#xff0c;同时让新手对…

C++ class实现Huffman树(完整代码)

代码如下: #include <iostream> #include <string> using namespace std; const unsigned int n 8;//字符数NUM&#xff0c;这里的字符数为8 const unsigned int m 2 * n - 1;//结点总数 const float MAX 1e8;class HTNode {friend class HuffmanTree; private…

二叉树的遍历(堆栈)

二叉树的遍历&#xff08;堆栈&#xff09; 如何理解用堆栈方式代替递归去遍历二叉树&#xff0c;关键点在于了解每个结点输出时的顺序&#xff0c;以及理解前序中序后序是如何遍历的&#xff0c;这点很重要&#xff0c;可以自己画一个树图&#xff0c;熟练写出遍历的结果 以…

ASP.NET Core分布式项目实战(第三方ClientCredential模式调用)--学习笔记

任务10&#xff1a;第三方ClientCredential模式调用创建一个控制台程序dotnet new console --name ThirdPartyDemo添加 Nuget 包&#xff1a;IdentityModel添加之后还原dotnet restoreClientusing System; using System.Net.Http; using System.Threading.Tasks; using Identit…

C++ class实现邻接矩阵存储的图(完整代码)

代码如下: #include <iostream> #include <queue> using namespace std; typedef int VertexType; typedef int EdgeType; const int MaxVertexNum 30;class MGraph { public:MGraph(){CreatGraph();};void CreatGraph();void Visit(int v);void BFS(int v);void…

二叉搜索树(创建,插入,删除):基础篇,适合新手观看。

1.1 二叉搜索树的插入 二叉搜索树的概念相信大家都很清楚&#xff0c;无非就是左小右大 创建二叉搜索树&#xff0c;其实就是多次调用二叉搜索树的插入方法&#xff0c;所以首先我们来讲讲如何插入节点到二叉搜索树里&#xff0c;假设一颗二叉搜索树如下&#xff0c;现在要插入…

.NET 的过去、现在和未来

作为一名使用了十多年.NET 开发平台的开发者来说&#xff0c;多年来笔者鲜少在各类博客媒体推广布道 .NET平台&#xff0c;但也默默地关注着 .NET 平台的发展变化&#xff0c;为每一次新技术的出现欢呼&#xff0c;为近年来 .NET 的沉默感到惋惜&#xff0c;也为开放开源的 .NE…

AVL树(平衡二叉树)讲解,入门篇,适合新手观看

1.1 概念 平衡二叉树就是为了让二叉搜索树的平均查找长度更短&#xff0c;时间复杂度更靠近logN,如果一个二叉搜索树不平衡了就会出现图1情况&#xff0c;完全变成一个数组&#xff0c;时间复杂度也变为了O(N)。 平衡因子&#xff1a;平衡因子就是针对于树中某一结点&#xff…

读源码,对开发者重要吗?

.NET 5.0 Peview 2发布4月2日&#xff0c;微软发布了.NET 5.0 Preview 2&#xff0c;带来功能和性能方面的改进。这一版本包括.NET 5.0.0 Preview 2与.NET SDK 5.0.100 Preview 2。.NET 5是.NET Framework和.NET Core的未来&#xff0c;最终将成为一个统一平台&#xff0c;.NET…

Redis和DB数据一致性解决方案

问题出现原因 并发时候无法保证读写的先后顺序&#xff0c;如果删掉了缓存还没来得及写库&#xff0c;另外一个县城就多来读取&#xff0c;发现缓存为空就去读取数据库并且写入缓存&#xff0c;这时候缓存中就是脏数据如果先写库&#xff0c;在删除缓存前&#xff0c;写库的线…

数据结构堆的时间复杂度(最大堆,最小堆)

创建堆的方式有两种&#xff0c;一种是一边插入结点&#xff0c;一边调用堆的插入方法调整堆&#xff0c;这样的时间复杂度就是 O(NlogN)&#xff0c;而第二种方式就把时间复杂度缩减到了O(N)&#xff0c;它是采用先把结点插入好了&#xff0c;然后再来调整堆&#xff0c;并不是…

中国速度之二神山建设(3):有力的技术保障,基建世界里的云原生缩影 | IDCF DevOps案例研究...

内容来源&#xff1a;DevOps案例深度研究第4期 – 火神山雷神山 DevOps实践研究战队&#xff08;本文只展示部分PPT及研究成果&#xff0c;全程视频请移步文末&#xff09;本案例内容贡献者&#xff1a;赖泽薇、张扬、邓茜芸、韦一、刘德权、候利涛、冯利娟、常相宇、张力、韩丰…

Rx2.0后台开发分享

Rxjava2.x 微软的一个函数库&#xff0c;Rx是一个编程模型&#xff0c;模板是提供一致的编程接口&#xff0c;帮助开发者更方便的处理异步数据流&#xff0c;现在Rx已经支持几乎全部的流行编程语言。比较流行的有Rxjava&#xff0c;RxJs&#xff0c;Rx.NET&#xff0c;社区网站…

中国速度之二神山建设(4):全能运维,召之即来,来之即战 | IDCF DevOps案例研究...

内容来源&#xff1a;DevOps案例深度研究第4期 – 火神山雷神山 DevOps实践研究战队&#xff08;本文只展示部分PPT及研究成果&#xff0c;全程视频请移步文末&#xff09;本案例内容贡献者&#xff1a;赖泽薇、张扬、邓茜芸、韦一、刘德权、候利涛、冯利娟、常相宇、张力、韩丰…

Spring Cloud部分源码分析Eureka,Ribbon,Feign,Zuul

Eureka SpringCloud Eureka使用NetFlix Eureka来实现的&#xff0c;它包括了服务端组件和客户端组件&#xff0c;并且都是用java 编写的。 Eureka服务端就是服务注册中心&#xff0c;Eureka客户端主要处理服务的注册发现&#xff0c;通过注解和参数配置的方式&#xff0c;客户…

C++ class实现邻接表存储的图(完整代码)

代码如下: #include <iostream> #include <queue>using namespace std; const int MaxVertexNum 30; typedef int InfoType; typedef int VertexType;class Node {friend class VNode;friend class ALGraph; private:int adjvertex;InfoType info;Node *next; };…

世界上首个被数学证明安全的OS微内核seL4成立基金会

Linux 基金会正在与澳大利亚国家科学机构 CSIRO 合作&#xff0c;打造 seL4 操作系统微内核生态。近日 Linux 基金会宣布托管 seL4 基金会&#xff0c;该基金会以澳大利亚国家科学机构 CSIRO 的数字机构 Data61 创建的 seL4 操作系统微内核为基础项目。seL4 是一个安全操作系统…

数据结构(哈夫曼树,哈夫曼编码)入门篇,JAVA实现

什么是哈夫曼树 哈夫曼树就是一种最优判定树&#xff0c;举个例子&#xff0c;如下一个判断逻辑 if(s<60) g1; else if(s<70) g2 else if(s<80) g3 else if(s<90) g4 else g5; 分数概率图如下 如果按照代码从上到下顺序构造判定树&#xff0c;那么如下图所示&am…