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

本文概述

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

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

单元测试教程:封面插图

什么是单元测试?

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

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

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

什么是单元测试:插图

我将在本文的所有示例中使用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

        // 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输入问题-唯一的区别是它位于抽象级别的高一点。为了解决此问题, 我们可以引入另一个参数, 再次委托将具有签名ActuateLights(bool motionDetected, DateTime 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将负责打开或关闭灯的责任委托给实现了Singleton模式的BackyardLightSwitcher对象。这种设计有什么问题?

为了对ActuateLights(bool motionDetected)方法进行完全的单元测试, 除了基于状态的测试之外, 我们还应该执行基于交互的测试。也就是说, 我们应该确保只有在满足适当条件的情况下, 才能调用打开或关闭灯的方法。不幸的是, 当前的设计不允许我们这样做:BackyardLightSwitcher的TurnOn()和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.Now或Environment.MachineName;它们是只读的, 但仍不确定)。

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

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

单身人士

本质上, 单例模式只是全局状态的另一种形式。单例促进了模糊的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质量的角度来看, 这种方法是绝对正确的。

新的运营商

更新对象的实例以完成某些工作会带来与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)); }

单元测试的好处

显然, 编写可测试的代码需要一定的纪律, 专心和额外的精力。但是无论如何, 软件开发都是一项复杂的思维活动, 我们应该始终谨慎, 避免鲁our地把新代码扔到头上。

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

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?