单元测试,及如何写出可测试的代码

本文翻译自Sergey Kolodiy 在 Toptal.com 的一篇文章,已获得作者授权。感谢Sergey的分享。
This article is translated from Sergey Kolodiy's post on Toptal.com. Much appreciation to Sergey.

我对单元测试的兴趣源自于工作中遇到的挫折。一开始写代码的时候我虽然也遵循了命名规范、代码风格之类的基本原则,但是觉得写测试比较耗费时间,测试驱动开发更是只应该存在于理想世界的东西。然而当我编写的程序规模越来越大,代码越来越多时,我渐渐感觉维护起来力不从心,每次对缘由代码做修改,甚至重构时,都会提心吊胆,生怕改出什么大bug来。直到这时我才意识到,语与其话费时间与不断地调试和返工,不如一开始老老实实地写测试,保证代码质量。

但后来我为已有代码补测试的时候,为新代码覆盖测试的时候,总感觉写出来的测试很别扭,很不优雅:我就测试一个小小的方法,居然要搭建数据库,然后又是setUp又是tearDown。写单元测试都这么累的吗?写单元测试都要掌握一些高端技巧的吗?后来我才了解到,原来我写的代码根本就untestable。所以我上网找怎样写可测试代码的指南。这一份则是我读过的最好的教程。当然,最经典的资源可能要数Google大神Misko Hevery主编的 Writing Testable Code,但相比于38页详尽的Guide来说,我个人认为这一份教程更适合入门。感谢作者Sergey Kolodiy,你贡献出来的知识和经验让我受益匪浅。

这么好的东西不能独占啊,应该分享出去才行。在征得作者的同意后,我把这份教程翻译成了中文,希望对国内还在挣扎的同学有用,读完后早日脱离苦海。当然,这么长的文章我是没时间字斟句酌地翻译的,所以采用了机器翻译+人工修饰的方法,同时出于自身能力的局限,未免有些地方翻译得不通顺。有能力的读者直接阅读原文就好,领略作者原汁原味的思想。话说现在的开发者不懂英文很难活得下去吧······

以下是正文部分。


单元测试是任何认真的软件开发者必不可少的工具。 但是,有时候为特定的代码编写好的单元测试是很困难的。 在感觉难以为自己或别人的代码编写单元测试时,开发者往往认为这是由于自己缺乏一些基本的测试知识或神秘的单元测试技术引起的。

在这份单元测试教程中,我打算证明单元测试非常简单;使单元测试变得困难并引入复杂性的真正原因是设计不良难以测试的代码。 我们将讨论:

  1. 使代码难以测试的原因,
  2. 应该避免反模式(anti-patterns)不良实践(bad practices)来提高可测试性,
  3. 以及通过编写可测试代码可以获得的其他好处。

我们将意识到编写单元测试和可测试代码不仅仅是为了减少测试问题,而且能使代码本身更健壮,更易于维护。

[图片上传失败...(image-29ae4e-1555172741807)]

什么是单元测试?

从本质上讲,单元测试是把我们的程序中一小部分代码单独拎出来,独立于程序的其他部分,然后验证其表现和行为的测试方法。典型的单元测试包含3个阶段:

  1. 首先,它将想要测试的一小部分程序代码拎出来,做一些初始化工作,比如实例化对象(这部分代码也称为被测系统/ system under test,或SUT)。
  2. 然后对被测系统做一些测试(通常是调用它的方法)。
  3. 最后,观察被测系统的行为。 如果观察到的行为与预期一致,则单元测试通过,否则失败,表明被测系统中某处存在问题。

这三个单元测试阶段也称为准备(Arrange),执行 (Act)和断言(Assert),或简称为AAA。

单元测试可以验证被测系统不同方面的行为,但主要来说可以分为下面两大类:基于状态的测试基于交互的测试。 验证被测系统产生的结果是否正确,或者结果状态是否正确,称为基于状态的单元测试;而验证它是否正确调用某些方法称为基于交互的单元测试。

打个比方,想象一个疯狂的科学家想要创造一些超自然的生物,有青蛙腿,章鱼触手,鸟翅和狗的头部(这个比喻与程序员在工作中实际做的非常接近)。那位科学家将如何确保他所选择的每个部分(或单元)有效、可用? 好吧,他可以先拿一只青蛙的腿,对它施加电刺激,并检查肌肉收缩是否合适。 他所做的基本上与单元测试的准备- 执行 - 断言步骤相同;唯一的区别是,在这种情况下,单元(unit)指的是物理对象,而不是我们构建程序的抽象对象。

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

[TestMethod]
public void IsPalindrome_ForPalindromeString_ReturnsTrue()
{
    // 在准备(Arrange)阶段, 我们构造了一个被测系统(SUT)
    // 一个被测系统可以是一个方法,一个单独的对象或者是一组相互关联的对象
    // 当然,如果没什么要准备的话也可以跳过准备阶段,比如测试一个静态方法
    PalindromeDetector detector = new PalindromeDetector(); 

    // 在执行阶段我们要对被测系统施加这样那样的刺激,通常是调用用一个对象的方法
    // 如果被调用的方法有返回值,我们通常会看看返回值是否正确
    // 如果调用的方法没有返回值,那我们会看看这个方法是否产生了正确的副作用(做了正确的操作)
    bool isPalindrome = detector.IsPalindrome("kayak");

    // 断言阶段决定我们的单元测试是否通过
    // 这里我们检查被测系统的行为是否符合预期
    Assert.IsTrue(isPalindrome);
}

单元测试 VS 集成测试

有一点特别需要注意的是单元测试和集成测试之间的区别。

软件工程中单元测试的目的是不受其他部分影响,独立于其他部分,只验证某一小部分代码的行为。 单元测试的范围很窄,但允许我们覆盖所有业务情况,确保每个部件都能正常工作。

另一方面,集成测试确保系统的不同部分在现实环境中协同工作。 它们验证了复杂的场景(我们可以将集成测试看成一个用户在我们的系统中执行某些高层次的综合操作,而不是仅仅使用某一个部件的某一个功能),并且通常需要使用外部资源,如数据库或Web服务器。

让我们回到我们疯狂的科学家比喻。假设他已成功地组装好超自然生物体的所有部分,他希望对创造出来的生物进行整体测试,确保它可以在不同类型的地形上行走。 首先,科学家必须模拟出一个生物体行走的环境。 然后,他将生物扔进那个环境并用棍子戳它,观察它是否按照设计行走和移动。在完成测试后,这位疯狂的科学家还需清理散落在他那可爱的实验室里的所有泥土,沙子和岩石。

unit testing example illustration

请注意单元测试和集成测试之间的显着差异:单元测试验证应用程序的一小部分的行为,与环境和其他部分隔离,并且非常容易实现!而集成测试则涵盖不同组件之间的交互, 贴近现实生活的环境,需要更大的工作量,包括额外的前期准备和后期清理阶段。

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

但是,我们必须明确我们正在做的是哪一项测试:是单元测试还是集成测试。 这两者的差异有时可能难以区分。 如果我们认为我们正在编写一个单元测试来验证业务逻辑类中的一些微妙边缘情况,但却意识到它需要使用到Web服务或数据库等外部资源,那就意味着出问题了。实际上,我们正在使用大锤来 破解坚果(Essentially, we are using a sledgehammer to crack a nut.)。 这意味着糟糕的设计。

一个好的单元测试有哪些特质

在我们进入到本教程的主要部分并实操编写单元测试前,然我们先快速看一下好的单元测试有哪些特质。单元测试原则要求一个好的单元测试:

  • 易写。开发者通常会编写大量单元测试来覆盖应用程序不同情况、不同方面下的行为。因此所有这些测试例程应该很容易编写,而无需付出巨大努力。
  • 可读。单元测试的意图应该是明确的。一个好的单元测试反映了程序的一些功能和行为,包括如何使用、有哪些典型的使用场景。因此应该很容易能理解正在测试哪个场景 - 如果测试失败 - 很容易检测和定位到问题。有了良好的单元测试,我们可以在调试代码之前就发现并修复错误!
  • 可靠。只有在被测系统中存在错误时,单元测试才会失败。这似乎很理所当然,但程序员经常遇到一个问题——即使没有引入错误,他们的测试也会失败。例如,测试可以在逐个运行时通过,但在运行整个测试套件时失败,或者在我们的开发机上通过了却在持续集成服务器上失败。这些情况表明存在设计缺陷。良好的单元测试应该是可复现的,并且不受外部因素的影响,例如环境或运行顺序。
  • 快速。开发者编写单元测试的目的,是重复运行它们并检查是否有引入错误。如果单元测试运行地很慢,开发人员很可能跳过在自己的机器上运行测试。一个测试很缓慢不会有什么显著影响;然而当单元测试的规模变大,比如增加一千个测试,我们肯定回浪费一段时间去等待测试结束。慢速的单元测试还可能表明被测系统或单元测试本身与用到了外部资源,使测试依赖于环境。
  • 真正的单元测试,而不是集成测试。正如我们已经讨论过的,单元测试和集成测试有不同的用途。单元测试和被测系统都不应访问网络资源,数据库,文件系统等,以消除外部因素的影响。

就是这样:编写单元测试并没有什么神秘的技巧。 不过,倒是有一些技术让我们编写出可测试的代码

可测试代码 VS 不可测试代码

有些代码在编写时开发者没有遵守合理的代码规范,以至于很难、甚至不可能为这些代码写出好的单元测试。 那么,是什么让代码难以测试? 让我们回顾一下在编写可测试代码时应该避免的一些反模式( anti-patterns),代码异味(code smells)和不良实践(bad practices)。

存在非确定因素的有毒代码

让我们从一个简单的例子开始。 想象一下,我们正在编程实现一个智能家用微控制器,其中一个需求是如果在晚上(evening)或深夜(night)检测到有动作、有声响时,自动打开后院的灯光。 我们已经自下而上开始实现一个方法,返回大致时间(“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本质上是该方法的一个隐藏的输入,且它的值会在程序执行期间或测试运行期间发生变化。 因此,随后对它的调用将产生不同的结果。

这种非确定(non-deterministic)的行为使得必须先更改系统日期和时间然后才能测试GetTimeOfDay()方法的内部逻辑。 我们来看看如何实现这样的测试:

[TestMethod]
public void GetTimeOfDay_At6AM_ReturnsMorning()
{
    try
    {
        // 前期工作:把系统时间改成6AM
        ...

        // 准备阶段可以跳过:测试的是静态方法,无需实例化

        // 执行
        string timeOfDay = GetTimeOfDay();

        // 断言
        Assert.AreEqual("Morning", timeOfDay);
    }
    finally
    {
        // 善后工作: 把系统时间调回去
        ...
    }
}

像这样的测试会违反前面讨论的许多规则。这样的测试不容易编写(设置系统时间然后又调回去可不是非凡的工作),也不可靠(例如,由于系统权限问题,即使被测系统中没有错误,测试也可能会失败),并且不能保证快速执行。 而且,最后,这个测试实际上不是单元测试:它将是单元和集成测试之间的东西,因为它看似测试一个简单的边缘情况,但需要以特定方式设置环境。

所有这些可测试性问题都是由低质量的GetTimeOfDay()API引起的。考察一下目前的设计和实现,这个方法有几个问题:

  • 它与具体数据源紧密耦合。不能重用此方法来处理从其他数据源获取的日期和时间;该方法仅适用于执行该程序的特定计算机。紧耦合是大多数可测性问题的主要根源。
  • 它违反了单一责任原则(SRP)。该方法有多个责任:它消耗信息并处理信息。 SRP违规的一个判断标准是单个类或方法有多个原因会引起修改。从这个角度来看,修改GetTimeOfDay()方法的原因有两个:可以是内部逻辑调整,也可以是修改日期和时间的数据源。因此这个方法违反了SRP。
  • 它隐藏了完成工作所需要的信息(It lies about the information required to get its job done.)。开发者必须阅读实际源代码的每一行,才能了解方法使用到了哪些隐藏输入以及它们来自何处。仅仅是方法签名不足以理解方法的行为。
  • 它很难预测和维护。仅通过阅读源代码无法预测依赖于可变全局状态(在这里是系统时间)的方法会有怎样的行为:必须考虑这个可变全局状态的当前值,以及可能会改变它的所有事件。在真实世界的应用程序中,试图考虑所有这些东西是很让人头痛的。

在考察了这个API之后,让我们来修正它吧!幸运的是,这比讨论它的各种设计缺陷要容易得多——我们只需要打破紧耦合问题,分离关注点

改造API:引入一个方法参数

改造这个API,最明显也最容易的方式是引入一个方法参数(introducing a method argument)。

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()
{
    // 准备阶段可以跳过:测试的是静态方法,无需实例化

   // 执行
    string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00));

    // 断言
    Assert.AreEqual("Morning", timeOfDay);
}

请注意,通过分离关注点:要处理哪些数据和应该如何处理数据,这个简单的重构也解决了前面讨论的所有API问题(紧耦合,违反SRP,不可预测和难以理解的API)。

好极了!现在这个方法可以测试,但调用该方法的代码又该怎么办? 现在,调用者有责任为GetTimeOfDay(DateTime dateTime)方法提供日期和时间,如果我们没有考虑到调用者,它们也可能变得不可测试。 我们来看看如何处理这个问题。

改造调用程序:依赖注入

让我们继续研究上述智能家居系统,并编写一个调用GetTimeOfDay(DateTime dateTime)的程序——这段程序负责根据日期时间和检测到动作来打开或关闭后院里的灯:

public class SmartHomeController
{
    public DateTime LastMotionTime { get; private set; }

    public void ActuateLights(bool motionDetected)
    {
        DateTime time = DateTime.Now; // 这里出问题了!

        // 更新检测到动作的时间
        if (motionDetected)
        {
            LastMotionTime = time;
        }
        
        // 如果检测到有动作时处于晚上或深夜
        string timeOfDay = GetTimeOfDay(time);
        if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night"))
        {
            BackyardLightSwitcher.Instance.TurnOn();
        }
        // 若持续一分钟没有检测到动作或声响, 或者正处于早上或中午, 则把灯关掉
        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)API不用改变,同时也可测试,这种技巧就是控制反转(Inverse of Control,IoC)

控制反转是一种简单却又非常有用的解耦代码技巧,特别适合单元测试。毕竟,保持松散耦合对于能够彼此独立地分析各个单元至关重要。IoC的关键点是将控制代码(when to do something)与执行代码分开(what to do when things happen)。该技术提高了程序的灵活性,使我们的代码更加模块化,并减少了组件之间的耦合。

控制反转可以通过多种方式实现,让我们看一个例子:构造函数依赖注入,以及它怎样协助构建可测试的SmartHomeControllerAPI。

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

public interface IDateTimeProvider
{
    DateTime GetDateTime();
}

然后,让SmartHomeController使用IDateTimeProvider的一个实现去获取日期和时间:

public class SmartHomeController
{
    private readonly IDateTimeProvider _dateTimeProvider; // 一来

    public SmartHomeController(IDateTimeProvider dateTimeProvider)
    {
        // 将依赖注入到构造函数
        _dateTimeProvider = dateTimeProvider;
    }

    public void ActuateLights(bool motionDetected)
    {
        DateTime time = _dateTimeProvider.GetDateTime(); // 分离关注点

        // 剩余的开关灯控制逻辑...
    }
}

现在我们可以看到为什么这种技巧被称为控制反转:本来SmartHomeController的调用者应该依赖SmartHomeController去读取日期和时间和做其他事,但现在SmartHomeController却依赖于调用者提供读取日期时间的机制。 如今,ActuateLights(bool motionDetected)方法的执行取决于两件可以从外部轻松管理的东西:motionDetected参数和传递给SmartHomeController构造函数IDateTimeProvider的具体实现。

为什么这对单元测试很重要? 这意味着可以在生产代码和单元测试代码中使用不同的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 (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night"))
{
    BackyardLightSwitcher.Instance.TurnOn();
}
// 若持续一分钟没有检测到动作或声响, 或者正处于早上或中午, 则把灯关掉
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)方法接受另外两个参数:一对Actiondelegates函数,来打开和关闭灯。 这个解决方案将该方法转变为一个高阶函数

public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff)
{
    DateTime time = _dateTimeProvider.GetDateTime();
    
    // 更新检测到动作的时间
    if (motionDetected)
    {
        LastMotionTime = time;
    }
    
    // 如果检测到有动作时处于晚上或深夜
    string timeOfDay = GetTimeOfDay(time);
    if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night"))
    {
        turnOn(); // 调用一个负责开灯的函数,解耦代码
    }
    // 若持续一分钟没有检测到动作或声响, 或者正处于早上或中午, 则把灯关掉
    else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon"))
    {
        turnOff(); // 调用一个负责关灯的函数,解耦代码
    }
}

与我们之前看到的经典的面向对象式依赖注入方法相比,这是一个更具函数式编程风格的解决方案。但是,与依赖注入相比,它可以让我们以更少的代码和更强的表现力获得相同的效果。使用这种方案,我们不再需要设计一个借口,然后又实现一个符合接口规范的类来为SmartHomeController提供所需的功能。我们需要做的只有传递一个函数。 高阶函数可以被认为是实现控制反转的一种方式(其实本质上也是依赖注入)。

现在,要对这个方法编写基于交互的单元测试,我们可以将容易验证的“假”Action函数传递到其中:

[TestMethod]
public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight()
{
    // 准备阶段:构造两个仅仅是改变某个变量的布尔值的Action函数
    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)));

    // 执行
    controller.ActuateLights(true, turnOn, turnOff);

    // 断言
    Assert.IsTrue(turnedOn);
}

最后,经过努力我们终于把SmartHomeControllerAPI改造得彻底可测试了,无论是基于状态爱是基于交互的单元测试都可以编写了。再次注意,除了提高可测试性之外,分离决策和执行有助于解决紧耦合问题,并且使得API更清晰,可重用。

杂质(Impurity)与可测试性(Testability)

不受控的非确定性因素和副作用两者对代码的破坏性影响是相似的。如果使用不当,就会导致具有欺骗性,难以理解和维护,紧耦合,不可重用和不可测试的代码。

另一方面,确定性和无副作用的方法更容易测试,预测和重用以构建更大的程序。在函数式编程里,这些方法(指对象里的方法)称为纯函数。我们在为纯函数编写单元测试的时候很少有问题。我们所要做的仅仅是传递一些参数并检查结果是否正确。真正使代码不可测试的是哪些被硬编码的,不可替代的因素,不能以任何其他方式替换,覆盖或抽象。

杂质是有毒的:如果方法Foo()依赖于非确定性或副作用方法Bar(),那么Foo()也包含了非确定性或副作用。最终,我们最终可能会毒害整个代码库。想象一下所有这些问题乘,以及现实中应用程序的规模,我们将发现自己陷入了难以维护的代码库,充满了异味,反模式,秘密依赖以及各种丑陋和让人不快的东西( We’ll find ourselves encumbered with a hard to maintain codebase full of smells, anti-patterns, secret dependencies, and all sorts of ugly and unpleasant things.)。

unit testing example: illustration

然而,杂质是不可避免的。任何现实中的应用程序在某些时候都必须与环境,数据库,配置文件,Web服务或其他外部系统交互。 因此,我们的目标不应该是完全消除杂质,而是限制这些因素,避免让它们毒害整个代码库,并尽可能地消灭被硬编码的依赖关系,以便能够独立地分析各部件和进行单元测试。

一些常见警告标志

感觉写测试的时候举步维艰?问题不在你的测试套件中,而是在你的代码中。

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

静态属性

静态属性和字段,或简单地说,全局状态,会隐藏方法执行时用到信息,引入非确定性,或引入使用副作用,使得代码理解和测试变得复杂。 读取或修改可变全局状态的函数是不纯的。

例如,我们很难预测以下代码的行为,因为这取决于可修改的全局属性CostSavingEnabled

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

如果HeatWater()方法在它应该被调用时没有被调用怎么办? 由于应用程序的任何部分都可能已更改CostSavingEnabled值,因此我们必须分析所有可能改动这个变量的代码,才能找出错误。 此外,正如我们已经看到的,不能出于测试的目的而添加一些静态属性(例如,DateTime.NowEnvironment.MachineName。虽然它们是只读的,但仍然是非确定性的)。

另一方面,不可变和确定性的全局状态则是完全可以的,事实上它就是一个常数。 像Math.PI这样的常量值不会引入任何非确定性,并且由于它们的值无法更改,因此不产生任何副作用:

 // 这依然是一个纯函数
double Circumference(double radius) { return 2 * Math.PI * radius; }

单例

从本质上讲,单例模式就是一种全局状态。 单例会使得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

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

(译者注:我觉得这里作者想要强调的是区分可变数据和不可变数据或者工具实例。如果某个实例承担数据容器的职责,那本质上就是可变的全局变量,应该避免。然而某单个实例虽然和单例模式造出来的东西很像,但其实只是一个工具,比如像上面提到的DateTime工具或者Action函数,那么只要不写死在代码里,就可以放心大胆地使用。这两段写得有点含糊,看不明白的读者建议直接读原文。)

new 操作符

为了完成某些工作而实例化出一个对象,会引入与反单例模式相同的问题:隐藏的依赖,紧耦合、可测试性差以及不清晰的API。

例如,为了测试当返回404状态码时以下循环是否停止,开发者就需要搭建一个测试用的Web服务器,这显然是一件令人很头痛的事情:

using (var client = new HttpClient())
{
    HttpResponseMessage response;
    do
    {
        response = await client.GetAsync(uri);
        
        // 处理响应并更新uri...
        
    } while (response.StatusCode != HttpStatusCode.NotFound); // 404时停止
}

但是,有时候new操作符是无害的,可以放心使用,那就是在构造简单的实体对象时:

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

创建一个不会产生任何副作用的小型临时对象是没问题的。同样,如果只修改自己的状态,然后根据该状态返回结果,那也是没问题的。 在下面的示例中,我们不关心stack的方法有没有调用,我们只关心最终的返回值是否正确:

string ReverseString(string input)
{
    // 没必要做测试stack的方法有没有被调用(基于交互的测试)
    // 编写单元测试时只需检查返回值正确(基于状态的测试)
    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时开发者的大脑不会受到损伤。 毕竟,可测试代码的最终优势不仅在于可测试性本身,还在于能够轻松理解,维护和扩展代码。


本作品首发于简书 ,采用知识共享署名 4.0 国际许可协议进行授权。


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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270