作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
谢尔盖·科洛迪的头像

谢尔盖Kolodiy

谢尔盖是一名软件工程师,拥有丰富的开发经验 .. NET技术栈,具有强大的体系结构 & 编程技能.

专业知识

以前在

有限公司
分享

单元测试是任何严肃的程序员工具箱中必不可少的工具 软件开发人员. 然而, 有时候,要知道如何为一段特定的代码编写单元测试是相当困难的. 难以测试自己或他人的代码, 开发人员通常认为他们的困难是由于缺乏一些基本的测试知识或秘密的单元测试技术造成的.

在本单元测试教程中, I intend to demonstrate that 单元测试 are quite easy; the real problems that complicate 单元测试, 引入昂贵的复杂性, 是设计不良的结果吗, 不可测试 code. 我们将讨论是什么使代码难以测试, 为了提高可测试性,我们应该避免哪些反模式和不良实践, 以及我们可以获得的其他好处 编写可测试代码. 我们将看到编写单元测试和生成可测试代码不仅仅是为了减少测试的麻烦, 而是让代码本身更健壮, 更容易维护.

单元测试教程:封面插图

什么是单元测试?

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

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

作为一个适当的软件单元测试示例的隐喻, 想象一个疯狂的科学家想要制造一些超自然的东西 嵌合体它长着青蛙腿、章鱼触手、鸟翅膀和狗头. (这个比喻非常接近程序员在工作中实际做的事情). 这位科学家如何确保他挑选的每个部件(或单元)都能正常工作? 好吧, 他可以, 假设, 一只青蛙的腿, 对它进行电刺激, 检查肌肉是否正常收缩. What he is doing is 本质上 the same Arrange-行为-断言 steps of the 单位 test; the only difference is that, 在这种情况下, 单位 引用一个物理对象,而不是我们用来构建程序的抽象对象.

什么是单元测试:说明

本文中的所有示例都将使用c#, 但是所描述的概念适用于所有面向对象的编程语言.

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

(TestMethod)
IsPalindrome_ForPalindromeString_ReturnsTrue()
{
    //在Arrange阶段,我们创建并设置一个待测系统.
    //被测试的系统可以是一个方法、一个对象或一个连接对象的图.
    //有一个空的Arrange阶段是可以的,例如,如果我们正在测试一个静态方法-
    //在这种情况下,SUT已经以静态形式存在,我们不需要显式初始化任何东西.
    PalindromeDetector检测器= 新 PalindromeDetector(); 

    / /行为阶段是我们测试系统的阶段,通常是通过调用一个方法.
    //如果这个方法返回一些东西给我们, 我们想要收集结果以确保它是正确的.
    // Or, If方法不返回任何东西, 我们想检查它是否产生了预期的副作用.
    bool isPalindrome =检测器.IsPalindrome(“kayak”);

    / /维护阶段决定单元测试通过或失败.
    //这里我们检查方法的行为是否与预期一致.
    断言.IsTrue (isPalindrome);
}

单元测试vs. 集成测试

另一个需要考虑的重要问题是单元测试和集成测试之间的区别.

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

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

让我们回到疯狂科学家的比喻, 假设他成功地将嵌合体的所有部分结合起来. 他想对生成的生物进行整合测试, 确保它可以, 假设, 在不同的地形上行走. 首先,科学家必须模拟这种生物行走的环境. 然后, 他把这个生物扔到那个环境中,用一根棍子戳它, 观察它是否像设计的那样行走和移动. 测试结束后, 疯狂的科学家清理了所有的污垢, 沙子和岩石现在散落在他可爱的实验室里.

单元测试示例说明

注意单元测试和集成测试之间的显著区别: 单元测试验证应用程序一小部分的行为, 与环境和其他部分隔离, 而且很容易实现, 而集成测试则涵盖不同组件之间的交互, 在接近现实生活的环境中, 需要更多的努力, 包括额外的安装和拆卸阶段.

单元测试和集成测试的合理组合可确保每个单元都能正常工作, 独立于他人, 所有这些单位积分起来都很好, 这给了我们高度的信心,让我们相信整个系统都按照预期运行.

然而, 我们必须记住始终确定我们正在实现的测试类型:单元测试还是集成测试. 这种差异有时是具有欺骗性的. 如果我们认为我们正在编写单元测试来验证业务逻辑类中的一些微妙的边缘情况, 并意识到它需要外部资源,如web服务或数据库, 有些事情本质上是不对的, 我们在用大锤砸坚果. 这意味着糟糕的设计.

如何编写单元测试用例

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

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

  • 可读的. 单元测试的目的应该是明确的. 一个好的单元测试讲述了我们应用程序的一些行为方面的故事, 因此,应该很容易理解正在测试的场景,如果测试失败,也应该很容易发现如何解决问题. 有了一个好的单元测试,我们可以在不调试代码的情况下修复bug!

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

  • 快. 开发人员编写单元测试,以便他们可以重复运行它们,并检查没有引入错误. 如果单元测试很慢,开发人员更有可能跳过在自己的机器上运行它们. One slow test won’t make a significant difference; add one thous和 more 和 we’re surely stuck waiting for a while. 慢的单元测试也可能表明被测系统, 或者测试本身, 与外部系统交互, 使其依赖于环境.

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

就是这样——写作没有秘密 单元测试. 然而,有一些技巧允许我们编写 可测试的代码.

单元测试和编码:可测试和不可测试代码

有些代码是以这样一种方式编写的,这很难, 甚至不可能, 为它编写一个好的单元测试. 那么,是什么让代码测试变得棘手呢? 让我们回顾一些反模式, 代码味道, 以及我们在编写可测试代码时应该避免的坏习惯.

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

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

GetTimeOfDay ()
{
    DateTime time = DateTime.现在;
    如果(时间.Hour >= 0 && time.Hour < 6)
    {
        返回“晚上”;
    }
    如果(时间.Hour >= 6 && time.Hour < 12)
    {
        返回“早晨”;
    }
    如果(时间.Hour >= 12 && time.Hour < 18)
    {
        返回“下午”;
    }
    返回“晚上”;
}

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

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

这样的 不确定的 行为使得测试内部逻辑变得不可能 GetTimeOfDay () 方法,而不实际更改系统日期和时间. 让我们来看看这样的测试需要如何实现:

(TestMethod)
GetTimeOfDay_At6AM_ReturnsMorning()
{
    试一试
    {
        //设置:将系统时间更改为6 AM
        ...

        //安排阶段为空:测试静态方法,没有要初始化的东西

        / /行为
        GetTimeOfDay ();

        / /维护
        断言.AreEqual(“早上”,timeOfDay);
    }
    最后
    {
        // Teardown:回滚系统时间
        ...
    }
}

这样的测试将违反前面讨论的许多规则. 它的编写成本很高(因为设置和拆除逻辑非常重要)。, 不可靠(即使在被测系统中没有错误,它也可能失败), 由于系统权限问题, 例如), 而且不能保证跑得快. 和, 最后, 这个测试实际上不是单元测试——它介于单元测试和集成测试之间, 因为它假装测试一个简单的边缘情况,但需要以特定的方式设置环境. 结果不值得你这么努力?

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

  • 它与具体的数据源紧密耦合. 不可能重用此方法来处理从其他来源检索到的日期和时间, or passed as an argument; the 方法 works only with the date 和 time of the particular machine that executes the code. 紧密耦合是大多数可测试性问题的根源.

  • 它违反了 单一责任原则 (SRP). 的 方法 has multiple responsibilities; it consumes the information 和 also processes it. 违反SRP的另一个标志是当一个类或方法有多个时 改变的理由. 从这个角度来看, GetTimeOfDay () 方法也可能因内部逻辑调整而改变, 或者因为日期和时间源应该更改.

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

  • 它很难预测和维持. 的 behavior of a 方法 that depends on a mutable 全局状态 cannot be predicted by merely reading the source code; it is necessary to take into account its current 价值, 以及之前可能改变它的一系列事件. 在现实世界的应用程序中,试图解开所有这些东西是一个真正令人头痛的问题.

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

修复API:引入方法参数

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

GetTimeOfDay(DateTime)
{    
    如果(dateTime.Hour >= 0 && dateTime.Hour < 6)
    {
        返回“晚上”;
    }
    如果(dateTime.Hour >= 6 && dateTime.Hour < 12)
    {
        返回“早晨”;
    }
    如果(dateTime.Hour >= 12 && dateTime.Hour < 18)
    {
        返回“中午”;
    }
    返回“晚上”;
}

现在该方法要求调用者提供一个 DateTime 论点,而不是秘密地寻找这个信息本身. From the 单元测试 perspective, this is great; the 方法 is now deterministic (i.e.(它的返回值完全取决于输入),因此基于状态的测试就像传递一些一样简单 DateTime 值和检查结果:

(TestMethod)
GetTimeOfDay_For6AM_ReturnsMorning()
{
    //安排阶段为空:测试静态方法,没有要初始化的东西

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

    / /维护
    断言.AreEqual(“早上”,timeOfDay);
}

注意,这个简单的重构还解决了前面讨论的所有API问题(紧耦合), SRP违反, 不清晰且难以理解的API) 什么 数据应该被处理和 如何 应该这样做.

很好-方法是可测试的,但它的 客户? 现在是 调用者的 提供日期和时间的责任 GetTimeOfDay (DateTime DateTime) 方法,意思是 他们 会变得不可测试,如果我们不给予足够的关注. 让我们看看如何处理它.

修复客户端API:依赖注入

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

公共类smarthom控制器
{
    public DateTime LastMotionTime { get; private set; }

    公共void 行为uateLights (bool motionDetected)
    {
        DateTime time = DateTime.现在; // 哎哟!

        //更新最后一个动作的时间.
        如果(motionDetected)
        {
            LastMotionTime =时间;
        }
        
        //如果在晚上或晚上检测到运动,打开灯.
        string timeOfDay = GetTimeOfDay(time);
        如果(motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night"))
        {
            BackyardLightSwitcher.实例.接通开启();
        }
        //如果一分钟内没有动作,或者是早晨或白天,关闭灯.
        否则如果(时间).Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "上午" || timeOfDay == "中午"))
        {
            BackyardLightSwitcher.实例.避开();
        }
    }
}

哎哟! 我们有同样的隐藏 DateTime.现在 输入问题-唯一的区别是它位于抽象级别稍高的地方. 为了解决这个问题, 我们可以引入另一个论点, 再次委派提供 DateTime 值赋给具有签名的新方法的调用方 行为uateLights(bool motionDetected, DateTime, DateTime). 但, 而不是将问题再次移动到调用堆栈中的更高级别, 让我们采用另一种技术,使我们能够同时保留两者 行为uateLights (bool motionDetected) 方法及其客户端可测试: 控制反转,或IoC.

控制反转很简单, 但是非常有用, 解耦代码技术, 特别是对于单元测试. (毕竟, 保持事物松散耦合对于能够独立地分析它们是必不可少的.) IoC的关键是将决策代码( 做某事)从动作代码(什么 当某事发生时做某事). 这种技术增加了灵活性, 使我们的代码更加模块化, 并且减少了组件之间的耦合.

控制反转 can be implemented in a number of ways; let’s have a look at one particular example — 依赖注入 使用构造函数-以及它如何帮助构建可测试的 SmartHomeController API.

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

公共接口IDateTimeProvider
{
    DateTime GetDateTime ();
}

然后,让 SmartHomeController 引用一个 IDateTimeProvider 执行,并委托其负责获取日期和时间;

公共类smarthom控制器
{
    private readonly IDateTimeProvider _dateTimeProvider; // Dependency

    公共SmartHomeController(IDateTimeProvider)
    {
        //在构造函数中注入所需的依赖项.
        _dateTimeProvider = dateTimeProvider;
    }

    公共void 行为uateLights (bool motionDetected)
    {
        DateTime time = _dateTimeProvider.GetDateTime (); // Delegating the responsibility

        //剩余的光控制逻辑在这里...
    }
}

现在我们可以看到为什么控制反转被称为 控制 用什么机制来读取日期和时间 ,现在属于 客户端 of SmartHomeController,而不是 SmartHomeController 本身. 因此,执行 行为uateLights (bool motionDetected) 方法完全依赖于两个可以从外部轻松管理的东西 motionDetected 参数,并给出了具体实现 IDateTimeProvider,进入… SmartHomeController 构造函数.

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

的虚假实现 IDateTimeProvider 可以是这样的:

公共类FakeDateTimeProvider: IDateTimeProvider
{
    public DateTime ReturnValue { get; set; }

    public DateTime GetDateTime() { return ReturnValue; }

    public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; }
}

在这个类的帮助下,可以进行隔离 SmartHomeController 并执行基于状态的单元测试. 让我们验证一下,如果检测到运动,运动的时间记录在 LastMotionTime 属性:

(TestMethod)
空白行为uateLights_MotionDetected_SavesTimeOfMotion ()
{
    / /安排
    var 控制器 = 新 SmartHomeController(新 FakeDateTimeProvider(新 DateTime), 12, 31, 23, 59, 59)));

    / /行为
    控制器.行为uateLights(真正的);

    / /维护
    断言.AreEqual(新 DateTime(2015, 12, 31, 23, 59, 59),控制器.LastMotionTime);
}

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

用副作用毒害代码库

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

让我们回顾一下下面的部分 行为uateLights (bool motionDetected) 负责开灯或关灯的方法:

//如果在晚上或晚上检测到运动,打开灯.
如果(motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night"))
{
    BackyardLightSwitcher.实例.接通开启();
}
//如果一分钟内没有动作,或者是早晨或白天,关闭灯.
否则如果(时间).Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "上午" || timeOfDay == "中午"))
{
    BackyardLightSwitcher.实例.避开();
}

我们可以看到, SmartHomeController 将开灯或关灯的责任委托给 BackyardLightSwitcher 对象,它实现了 单例模式. 这个设计有什么问题?

的完整单元测试 行为uateLights (bool motionDetected) 方法, we should perform 基于交互 testing in addition to the 基于状态的 testing; that is, 我们应该确保打开或关闭灯的方法被调用if, 只有当, 满足适当的条件. 不幸的是,当前的设计不允许我们这样做 接通开启()避开() 的方法 BackyardLightSwitcher 触发系统中的一些状态变化,或者换句话说,产生 副作用. 验证这些方法是否被调用的唯一方法是检查它们相应的副作用是否实际发生, 这可能很痛苦.

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

我们可以看到, 单元测试副作用方法可能和单元测试不确定方法一样困难, 甚至可能是不可能的. 任何尝试都会导致类似我们已经看到的问题. 结果测试将很难实现,不可靠,可能很慢,并且不是真正的单元. 和, 毕竟, 每次我们运行测试套件时的闪动最终会让我们发疯!

同样,所有这些可测试性问题都是由糟糕的API造成的,而不是开发人员的能力 编写单元测试. 无论光线控制是如何精确地实现的 SmartHomeController API面临着这些已经很熟悉的问题:

  • 它与具体实现紧密耦合. 的硬编码的具体实例 BackyardLightSwitcher. 是不可能重用的 行为uateLights (bool motionDetected) 除后院的灯外,其他灯的开关方法.

  • 它违反了单一责任原则. 更改API有两个原因:首先, 改变内部逻辑(如选择使灯只在晚上打开), 但不是在晚上)和第二, 如果光开关机构被另一个取代.

  • 它在依赖关系上撒谎. 开发者不可能知道这一点 SmartHomeController 取决于硬编码 BackyardLightSwitcher 组件,而不是深入研究源代码.

  • 它很难理解和维护. 如果在条件合适的时候灯不亮怎么办? 我们可以花很多时间来修复 SmartHomeController 毫无用处,才意识到问题是由程序中的错误引起的 BackyardLightSwitcher (或者,更有趣的是,一个烧坏的灯泡!).

解决可测试性和低质量API问题的方法是, 不足为奇的是, 使紧密耦合的组件相互分离. As with the previous example, employing 依赖注入 would solve these issues; just add an ILightSwitcher 的依赖性 SmartHomeController,委托它负责拨动电灯开关,并通过一个假的,只测试 ILightSwitcher 实现,该实现将记录是否在正确的条件下调用了适当的方法. 然而, 而不是再次使用依赖注入, 让我们回顾一种有趣的解耦职责的替代方法.

修复API:高阶函数

这种方法在任何支持的面向对象语言中都是一种选择 一级函数. 让我们利用c#的函数特性来创建 行为uateLights (bool motionDetected) 方法接受另外两个参数:一对 行动 委托,指向应该被调用来打开和关闭灯的方法. 这个解决方案将把方法转换成 高阶函数:

public void 行为uateLights(bool motionDetected, 行动 on, 行动 off)
{
    DateTime time = _dateTimeProvider.GetDateTime ();
    
    //更新最后一个动作的时间.
    如果(motionDetected)
    {
        LastMotionTime =时间;
    }
    
    //如果在晚上或晚上检测到运动,打开灯.
    string timeOfDay = GetTimeOfDay(time);
    如果(motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night"))
    {
        turnOn(); // Invoking a delegate: no 紧密耦合 anymore
    }
    //如果一分钟内没有动作,或者是早晨或白天,关闭灯.
    否则如果(时间).Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "上午" || timeOfDay == "中午"))
    {
        turnOff(); // Invoking a delegate: no 紧密耦合 anymore
    }
}

This is a more functional-flavored solution than the classic object-oriented 依赖注入 approach we’ve seen before; 然而, 它让我们用更少的代码实现相同的结果, 更有表现力, 而不是依赖注入. 不再需要实现符合接口的类来提供 SmartHomeController with the required functionality; instead, we can just pass a function definition. 高阶函数可以被认为是实现控制反转的另一种方式.

现在, 对结果方法执行基于交互的单元测试, 我们可以向它传递容易验证的假操作:

(TestMethod)
行为uateLights_MotionDetectedAtNight_TurnsOn的Light()
{
    //安排:创建一对动作,改变布尔变量,而不是真正打开或关闭灯.
    bool turndon = false;
    行动 turnOn  = () => turnedOn = true;
    行动 turnOff = () => turnedOn = false;
    var 控制器 = 新 SmartHomeController(新 FakeDateTimeProvider(新 DateTime), 12, 31, 23, 59, 59)));

    / /行为
    控制器.行为uateLights(true, turn, off);

    / /维护
    断言.IsTrue (turnedOn);
}

最后,我们做了 SmartHomeController API完全可测试, 我们能够为它执行基于状态和基于交互的单元测试. 再一次。, 注意,除了改进的可测试性, 在决策代码和操作代码之间引入一个接缝有助于解决紧密耦合问题, 然后引出了一个清洁工, 可重复使用的API.

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

杂质和可测试性

不受控制的不确定性和副作用在对代码库的破坏性影响方面是相似的. 不小心使用时, 它们会导致欺骗, 很难理解和维护, 紧耦合的, 不可重复使用的, 不可测试的代码.

另一方面,方法都是确定性的 无副作用更容易测试、推理和重用以构建更大的程序. 在函数式编程中,这样的方法被称为 纯函数. We’ll rarely have a problem 单元测试 a 纯 function; all we have to do is to pass some arguments 和 check the result for correctness. 真正使代码不可测试的是硬编码, 不可替代的不纯因素, 覆盖, 或者以其他方式抽象出来.

杂质是有毒的:如果方法 Foo () 取决于不确定性或副作用的方法 酒吧(),然后 Foo () 变得不确定或者有副作用. 最终,我们可能会破坏整个代码库. 将所有这些问题乘以复杂的实际应用程序的大小, 我们会发现自己被一个充满异味的难以维护的代码库所拖累, 反模式, 秘密的依赖性, 还有各种丑陋和不愉快的事情.

单元测试示例:插图

然而, impurity is inevitable; any real-life application must, 在某一时刻, 通过与环境交互读取和操作状态, 数据库, 配置文件, web服务, 或者其他外部系统. 所以我们的目标不是完全消除不洁净, 限制这些因素是个好主意, 避免让它们毒害你的代码库, 并且尽可能地打破硬编码的依赖关系, 为了能够独立分析和单元测试.

难以测试代码的常见警告标志

在编写测试时遇到麻烦? 问题不在于您的测试套件. 它在你的代码里.

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

静态属性和字段

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

例如, 很难对下面的代码进行推理, 这取决于一个全局可访问的属性:

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

如果…… HeatWater () 方法在我们确定它应该被调用时没有被调用? 由于应用程序的任何部分都可能更改 CostSavingEnabled 价值, 我们必须找到并分析所有修改该值的地方,以便找出问题所在. 也, 正如我们已经看到的, 为测试目的而设置一些静态属性是不可能的.g., DateTime.现在, or 环境.MachineName; 他们 are read-only, but still 不确定的).

另一方面,不可变 确定性全局状态是完全可以的. 事实上,它有一个更熟悉的名字——常数. 常数值,比如 数学.PI 不引入任何非决定论, 和, 因为它们的值不能改变, 不允许有任何副作用:

double Circumference(double radius){返回2 * 数学.PI * radius; } // Still a 纯 function!

单例

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

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

GetUser(int userId)
{
    用户用户;
    如果(UserCache.实例.ContainsKey (userId))
    {
        user = UserCache.实例(userId);
    }
    其他的
    {
        user = _userService.LoadUser (userId);
        UserCache.实例[userId] = user;
    }
    返回用户;
}

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

Using 单例 is a bad practice that can (和 should) be avoided in most cases; 然而, 区分作为设计模式的Singleton是很重要的, 和对象的单个实例. 在后一种情况下, 创建和维护单个实例的责任在于应用程序本身. 通常, 这与工厂或依赖注入容器一起交付, 它在应用程序的“顶部”附近创建一个实例(i.e.(更靠近应用程序入口点),然后将其传递给需要它的每个对象. 从可测试性和API质量的角度来看,这种方法是绝对正确的.

操作符

为了完成某些工作而更新对象的实例引入了与单例反模式相同的问题:带有隐藏依赖关系的不清晰的api, 紧密耦合, 可测试性差.

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

使用(var 客户端 = 新 HttpClient())
{
    HttpResponseMessage反应;
    do
    {
        响应=等待客户端.GetAsync (uri);
        //处理响应并更新uri...
    } while(响应).StatusCode != HttpStatusCode.NotFound);
}

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

var person = 新 person(“John”,“Doe”,新 DateTime(1970, 12, 31));

创建一个小的, 不会产生任何副作用的临时对象, 除了修改自己的状态, 然后返回基于那个状态的结果. 在下面的例子中,我们不关心是否 堆栈 方法是否被调用-我们只是检查最终结果是否正确:

字符串ReverseString(字符串输入)
{
    //不需要进行基于交互的测试和检查堆栈方法是否被调用;
    //单元测试只需要确保返回值是正确的(基于状态的测试).
    var 堆栈 = 新 堆栈();
    Foreach(输入变量)
    {
        堆栈.推动(年代);
    }
    字符串结果=字符串.空的;
    而(堆栈.数 != 0)
    {
        Result += 堆栈.Pop ();
    }
    返回结果;
}

静态方法

静态方法是不确定或副作用行为的另一个潜在来源. 它们很容易引入紧耦合,使我们的代码无法测试.

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

空白CheckPath环境Variable ()
{

    如果(环境.Get环境Variable(“路径”) != null)
    {
        控制台.PATH环境变量存在.");
    }

    其他的
    {
       控制台.没有定义PATH环境变量.");
    }

}

然而, 静态函数是可以的:它们的任何组合仍然是一个纯函数. 例如:

double斜边(double side1, double side2){返回数学.√数学.Pow(side1, 2) + 数学.Pow(side2, 2)); }

适当的单元测试和编码的好处

显然,编写可测试代码需要一定的纪律、专注和额外的努力. 但无论如何,软件开发是一项复杂的心理活动, 我们应该时刻小心, 避免不顾一切地从我们的头脑中拼凑出新的代码.

作为对这种正当行为的奖励 软件质量保证, 我们最后会得到clean, 易于维护, 松散耦合的, 和可重用的api, 当开发者试图理解游戏时,这不会损害他们的大脑. 毕竟,最终的优势 可测试的代码 不只是可测试性本身吗, 而是容易理解的能力, 还要维护和扩展该代码.

了解基本知识

  • 什么是单元测试?

    单元测试是一种实例化一小部分代码并独立于项目的其他部分验证其行为的方法.

  • 如何进行单元测试,需要做什么?

    单元测试通常有三个不同的阶段, 行为, 和断言(有时称为AAA). 要使单元测试成功, 所有三个阶段的结果行为必须符合预期.

  • 什么是集成测试?

    集成测试侧重于作为一个组测试和观察不同的软件模块. 它通常在单元测试完成之后,在验证测试之前执行.

就这一主题咨询作者或专家.
预约电话
谢尔盖·科洛迪的头像
谢尔盖Kolodiy

位于 阿拉木图,阿拉木图地区,哈萨克斯坦

成员自 2014年10月27日

作者简介

谢尔盖是一名软件工程师,拥有丰富的开发经验 .. NET技术栈,具有强大的体系结构 & 编程技能.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

专业知识

以前在

有限公司

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® 社区.