第12章 可测试性
测试会导致失效,而失效会带来理解。
—Burt Rutan
开发设计良好的系统的成本中,有很大一部分用于测试。如果经过深思熟虑的软件架构能够降低这一成本,回报将是巨大的。
软件可测试性指的是通过(通常基于执行的)测试来展示软件故障的难易程度。具体而言,可测试性是指假设软件至少存在一个故障,其在下一次测试执行中失败的概率。直观地说,如果一个系统能轻易“暴露”其故障,那么它就是可测试的。如果系统中存在故障,那么我们希望它在测试期间尽快失败。当然,计算这个概率并不容易,而且——正如我们在讨论可测试性的应对措施时你将看到的——会使用其他的衡量标准。此外,架构可以通过使复制错误和缩小错误可能的根本原因更容易来提高可测试性。我们通常不认为这些活动本身是可测试性的一部分,但最终仅仅揭示bug是不够的:您还需要找到并修复bug!
图 12.1展示了一个简单的测试模型,其中程序处理输入并产生输出。判定器是一个代理(人工或计算的),通过将输出与预期结果进行比较来确定输出是否正确。输出不仅是功能上产生的值,还可以包括质量属性的派生度量,例如产生输出所花费的时间。图 12.1还表明程序的内部状态可以展示给判定器,判定器可以确定该状态是否正确——也就是说,它可以检测程序是否进入错误状态,并对程序的正确性做出判断。设置和检查程序的内部状态是测试的一个方面,这在我们的可测试性策略中将占据重要地位。
图 12.1 测试模型
要使一个系统具有适当的可测试性,必须能够控制每个组件的输入(并且可能操纵其内部状态),然后观察其输出(并且可能在计算输出之后或在计算输出的过程中观察其内部状态)。通常,控制和观察是通过使用测试工具来实现的,这是一组专门设计用于测试软件的软件(在某些情况下,也可能是硬件)。测试工具具有各种形式,可能包括诸如跨接口发送的数据的记录和回放功能,或者用于测试嵌入式软件的外部环境的模拟器,甚至是在生产过程中运行的独立软件(请参阅侧栏“Netflix 的 Simian Army”)。测试工具可以在执行测试程序和记录输出方面提供帮助。测试工具及其配套的基础设施本身可能是相当重要的软件部分,具有自己的架构、利益相关者和质量属性要求。
NETFLIX的猿猴军团
网飞(Netflix)通过 DVD 和流媒体视频来发行电影和电视节目。其流媒体视频服务取得了极大的成功。事实上,在 2018 年,网飞的流媒体视频占据了全球互联网流量的 15%。自然地,高可用性对网飞来说很重要。
网飞(Netflix)在亚马逊 EC2 云平台上托管其计算机服务,该公司在其测试过程中使用了一系列最初被称为“猿猴军团”(Simian Army)的服务。网飞从“混沌猴子”(Chaos Monkey)开始,它会随机终止运行系统中的进程。这使得能够监测进程失败的影响,并能够确保系统不会因进程失败而出现故障或严重性能下降。
混沌猴子(Chaos Monkey)有了一些协助测试的朋友。除了混沌猴子,网飞的猿猴军团(Netflix Simian Army)还包括以下这些:
- 延迟猴子(Latency Monkey)在网络通信中引入人工延迟以模拟服务降级,并测量上游服务是否做出了适当的响应。
- 合规猴子(Conformity Monkey)会识别出不符合最佳实践的实例并将其关闭。例如,如果一个实例不属于自动缩放组,那么当需求增加时,它就无法进行适当的缩放。
- 医生猴子(Doctor Monkey)接入在每个实例上运行的健康检查以及监测其他外部健康迹象(例如,CPU 负载),以检测不健康的实例。
- 保洁猴子(The Janitor Monkey)确保网飞(Netflix)的云环境运行时没有杂乱和浪费。它会查找未使用的资源并进行处理。
- 安全猴子(The Security Monkey)是合规猴子(Conformity Monkey)的扩展。它会发现安全违规或漏洞,例如安全组配置不当,并终止违规实例。它还确保所有 SSL 和数字版权管理 (DRM) 证书有效且未临近更新。
- 10 - 18 猴子(本地化 - 国际化)检测为多个地理区域、使用不同语言和字符集的客户提供服务的实例中的配置和运行时问题。“10 - 18”这个名称来自“L10n - i18n”,这是“本地化”和“国际化”这两个词的一种简写形式。
猿猴军团的一些成员使用故障注入以可控和受监控的方式将故障引入运行中的系统。其他成员则监控系统及其环境的各个专门方面。这两种技术的适用范围都不仅仅局限于网飞。
鉴于并非所有故障在严重程度上都是等同的,相比发现其他故障,应更侧重于发现最严重的故障。“猿猴军团”反映了网飞的一个决心,即所针对的故障就其影响而言是最严重的。
网飞(Netflix)的策略表明,有些系统过于复杂和具有适应性,无法进行全面测试,因为它们的某些行为是突发的。在这种情况下,测试的一个方面是对系统产生的运行数据进行记录,以便在出现失效时,能够在实验室中分析记录的数据以尝试重现故障。
—LB
测试由各种开发人员、用户或质量保证人员进行。可以对系统的部分或整个系统进行测试。可测试性的响应措施涉及测试在发现故障方面的有效性以及达到某种期望的覆盖水平所需的测试时间。测试用例可以由开发人员、测试组或客户编写。在某些情况下,测试实际上推动了开发,就像测试驱动开发的情况一样。
代码测试是验证的一种特殊情况,它需要确保工程制品满足其利益相关者的需求或适合使用。在第 21 章中,我们将讨论架构设计评审——另一种验证,其中被测试的制品是架构。
12.1 可测试性的通用场景
表12.1 列举了表征可测试性的通用场景的元素。
表 12.1 可测试性的通用场景
场景部分 | 描述 | 可能的值 |
---|---|---|
来源 | 测试用例可以由人工或自动测试工具执行。 | 以下一项或多项:
|
触发事件 | 启动一个测试或一组测试。 | 这些测试用于:
|
环境 | 测试发生在各种事件或生命周期里程碑上。 | 由于以下原因执行测试集:
|
制品 | 被测试的制品是系统的一部分以及任何所需的测试基础设施。 | 被测试的部分:
|
响应 | 系统及其测试基础设施可以被控制以执行所需的测试,并且可以观察测试的结果。 | 以下一项或多项:
|
响应度量 | 响应度量旨在表示被测试系统有多容易“暴露”其故障或缺陷。 | 以下一项或多项:
|
图12.2 显示了可测试性的具体场景: 开发人员在开发过程中完成一个代码单元并执行一个测试序列,其结果被捕获,并在 30 分钟内提供 85% 的路径覆盖率。
图 12.2 可测试性场景示例
12.2 可测试性策略
可测试性策略旨在促进更轻松、更高效和更强大的测试。图 12.3 说明了可测试性策略的目标。用于增强软件可测试性的架构技术不像可修改性、性能和可用性等其他质量属性学科那样受到那么多关注,但正如我们之前所说,架构师为降低测试的高成本所做的任何事情都将产生显著的效益。
图 12.3 可测试性策略的目标
可测试性策略分为两类。第一类涉及为系统增加可控性和可观测性。第二类涉及限制系统设计的复杂性。
控制和监视系统状态
控制和观测对于可测试性至关重要,以至于一些作者正是基于这些方面来定义可测试性。这两者相辅相成;如果您无法观察到进行控制操作时所发生的情况,那么进行控制就没有意义。控制和观测的最简单形式是为软件组件提供一组输入,让其工作,然后观察其输出。然而,可测试性策略中关于控制和观测的类别对软件的洞察超出了其输入和输出。这些策略使组件能够维护某种状态信息,允许测试人员为该状态信息赋值,并根据需要向测试人员提供该信息。状态信息可能是操作状态、某些关键变量的值、性能负载、中间处理步骤,或者任何有助于重现组件行为的其他有用信息。具体策略包括以下内容:
-
专用接口:拥有专门的测试接口,使你能够通过应用测试工具或正常执行来控制或捕获组件的变量值。专门的测试例程示例(其中一些可能除了测试目的外不可用)包括以下这些:
- 针对重要变量、模式或属性的“设置”(set)和“获取”(get)方法
- 返回对象完整状态的“报告”(report)方法
- 将内部状态(例如,类的所有属性)设置为指定内部状态的“重置”(reset)方法
- 用于开启详细输出、不同级别事件日志记录、性能检测或资源监控的方法
专门的测试接口和方法应明确标识出来,或与所需功能的访问方法和接口分开,以便在需要时可以将其删除。然而,请注意,在性能关键和某些安全关键系统中,部署与测试不同的代码是有问题的。如果您删除测试代码,您如何知道发布的代码与您测试的代码具有相同的行为,特别是相同的时间行为?因此,此策略对于其他类型的系统更有效。
-
记录/回放:导致故障的状态通常难以重新创建。在状态跨越接口时记录该状态,允许使用该状态“回放系统”并重新创建故障。“记录”指的是捕获跨越接口的信息,“回放”指的是将其用作进一步测试的输入。
-
本地化状态存储:为了在任意状态下启动系统、子系统或组件进行测试,如果该状态存储在一个单一的位置,那将是最方便的。相比之下,如果状态被隐藏或分布,这种方法即使不是不可能,也会变得困难。状态可以是细粒度的,甚至是位级别的,也可以是粗粒度的,以表示广泛的抽象或整体操作模式。粒度的选择取决于在测试中如何使用这些状态。一种方便的“外部化”状态存储的方式(即,使其通过接口特性易于操作)是使用状态机(或状态机对象)作为跟踪和报告当前状态的机制。
-
抽象数据源:与控制程序状态的情况类似,能够控制其输入数据会使测试更容易。对接口进行抽象可以让你更轻松地替换测试数据。例如,如果你有一个客户交易数据库,你可以设计你的架构,以便你能够轻松地将你的测试系统指向其他测试数据库,甚至可能指向测试数据文件,而无需更改你的功能代码。
-
沙盒:“沙盒化”指的是将系统的一个实例与现实世界隔离开来,以便进行实验,而无需担心必须撤销实验的后果。能够以这样一种方式操作系统,即它没有永久性后果,或者任何后果都可以回滚,这有助于测试。沙盒策略可用于场景分析、培训和仿真。特别是在现实世界中的可能失效导致严重后果的情况下,仿真是一种常用于测试和培训的策略。
沙盒化的一种常见形式是虚拟化资源。测试一个系统通常涉及与行为不受系统控制的资源进行交互。使用沙盒,您可以构建一个行为受您控制的资源版本。例如,系统时钟的行为通常不受我们控制 —— 它每秒递增一秒。因此,如果我们想让系统认为在所有数据结构都应该溢出的那一天是午夜,我们需要一种方法来实现,因为等待不是一个好选择。当我们能够将系统时间从时钟时间中抽象出来时,我们可以让系统(或组件)以比实际时钟更快的速度运行,并在关键时间边界(如夏令时的下一次转换)测试系统(或组件)。对于其他资源,如内存、电池、网络等,也可以进行类似的虚拟化。桩、模拟和依赖注入是简单但有效的虚拟化形式。
-
可执行断言:使用此策略,断言(通常)是手动编码的,并放置在所需的位置,以指示程序何时何地处于错误状态。断言通常旨在检查数据值是否满足指定的约束。断言是根据特定的数据声明定义的,并且必须放置在数据值被引用或修改的位置。断言可以表示为每个方法的前置条件和后置条件,也可以表示为类级不变量。这增加了系统的可观测性,因为断言可能会被标记为失败。在数据值发生变化的位置系统地插入断言可以被视为生成“扩展”类型的手动方式。本质上,用户正在用额外的检查代码为类型添加注释。每当该类型的对象被修改时,检查代码会自动执行,如果违反任何条件,就会生成警告。在断言涵盖测试用例的范围内,它们有效地将测试预言嵌入到代码中 —— 假设断言是正确的并且编码正确。
所有这些策略都为软件添加了一些能力或抽象,如果我们对测试不感兴趣,那么这些就不会存在。它们可以被视为为基本的、完成工作的软件增加了更精细的软件,这些软件具有一些旨在提高测试效率和效果的特殊能力。
除了可测试性策略外,还有许多技术可用于将一个组件替换为其不同版本以方便测试:
- 组件替换 只是将一个组件的实现换成具有(在可测试性方面)便于测试的特性的不同实现。组件替换通常在系统的构建脚本中完成。
- 预处理器宏,在激活时,可以扩展为状态报告代码或激活返回或显示信息的探测语句,或将控制权返回给测试控制台。
- 方面(在面向方面的程序中)可以处理如何报告状态的横切关注点
限制复杂性
复杂的软件更难测试。它的操作状态空间很大,并且(在其他条件相同的情况下)在大状态空间中重新创建确切状态比在小状态空间中更难。因为测试不仅仅是让软件失败,还在于找到导致失效的故障以便将其消除,所以我们经常关注使行为具有可重复性。此类别包括两个策略:
-
限制结构复杂性:此策略包括避免或解决组件之间的循环依赖,隔离和封装对外部环境的依赖,以及总体上减少组件之间的依赖(通常通过降低组件之间的耦合来实现)。例如,在面向对象的系统中,您可以简化继承层次结构:
- 限制一个类派生自的类的数量,或者限制从一个类派生的类的数量。
- 限制继承树的深度,以及一个类的子类数量。
- 限制多态性和动态调用。
从经验上看,已被证明与可测试性相关的一个结构度量是类的“响应”。类 C 的响应是 C 的方法数量加上 C 的方法所调用的其他类的方法数量的总和。保持这个度量值较低可以提高可测试性。此外,架构级别的耦合度量,例如传播成本和去耦级别,可以用于测量和跟踪系统架构中的整体耦合水平。
确保系统具有高内聚、松耦合和关注点分离——所有可修改性策略(见第 8 章)——也有助于提高可测试性。这些特性通过给每个元素一个集中的任务,限制其与其他元素的交互,从而限制了架构元素的复杂性。关注点分离有助于实现可控性和可观测性,同时减小整个程序的状态空间大小。
最后,某些架构模式本身就有利于可测试性。在分层模式中,您可以先测试较低的层,然后在对较低层有信心的情况下测试较高的层。
-
限制不确定性:与限制结构复杂性相对应的是限制行为复杂性。在测试方面,不确定性是一种有害的复杂行为形式,不确定性系统比确定性系统更难测试。此策略包括找出所有不确定性的来源,例如不受约束的并行性,并尽可能地将其消除。有些不确定性的来源是不可避免的——例如,在对不可预测事件作出响应的多线程系统中——但对于此类系统,可以使用其他策略(如记录/回放)来帮助管理这种复杂性。
图12.4 总结了用于可测试性的策略。
图 12.4 可测试性策略
12.3 基于策略的可测试性问卷
基于12.2 节中描述的策略,我们可以创建一组受策略启发的问题,如表 12.2所示。为了全面了解为支持可测试性所做的架构选择,分析师会提出每个问题,并将答案记录在表中。然后,这些问题的答案可以成为进一步活动的重点:文档调查、代码或其他制品的分析、代码的逆向工程等等。
表12.2 基于策略的可测试性问卷
策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策和位置 | 原因和假设 |
---|---|---|---|---|---|
控制和观察系统状态 | 系统是否具有用于获取和设置值的专用接口? | ||||
系统是否有录制/播放机制? | |||||
系统的状态存储是否已本地化? | |||||
系统是否抽象其数据源? | |||||
部分或全部系统是否可以在沙盒中运行? | |||||
系统中是否有可执行断言的角色? | |||||
限制复杂性 | 系统是否以系统的方式限制了结构复杂性? | ||||
您的系统中是否存在不确定性,是否有方法来控制或限制这种不确定性? |
12.4 可测试性模式
可测试性模式都能使将特定于测试的代码从系统的实际功能中解耦变得更加容易。在此,我们讨论三种模式:依赖注入、策略和拦截过滤器。
依赖注入模式
在依赖注入模式中,客户端的依赖项与其行为是分离的。这种模式利用了控制反转。与传统的声明式编程不同,在传统编程中,控制和依赖关系明确存在于代码中,控制反转的依赖关系意味着控制和依赖关系由某些外部源提供,并注入到代码中。
在这种模式中,存在四个角色:
- 一项服务(您希望广泛提供的)
- 该服务的客户端
- 一个接口(被客户端使用,由服务实现)
- 一个注入器(创建服务的实例并将其注入客户端)
当接口创建服务并将其注入客户端时,编写客户端时无需了解具体的实现。换句话说,所有的实现细节通常在运行时被注入。
好处:
- 测试实例可以被注入(而非生产实例),并且这些测试实例可以管理和监控服务的状态。因此,编写客户端时无需知道如何对其进行测试。实际上,许多现代测试框架就是这样实现的。
权衡:
- 依赖注入使得运行时性能更难以预测,因为它可能会改变正在测试的行为。
- 采用这种模式会增加少量前期的复杂性,并且可能需要对开发人员进行重新培训,让他们从控制反转的角度思考。
策略模式
在策略模式中,一个类的行为可以在运行时改变。当执行给定任务可以采用多种算法,并且可以动态选择要使用的特定算法时,通常会采用这种模式。该类仅包含所需功能的抽象方法,此方法的具体版本是根据上下文因素选择的。这种模式经常用于将某些功能的非测试版本替换为提供额外输出、额外内部健全性检查等的测试版本。
好处:
- 这种模式通过不将多个关注点(例如同一函数的不同算法)合并到一个类中,使类变得更简单。
权衡:
- 与所有设计模式一样,策略模式会增加少量前期的复杂性。如果类很简单或者运行时的选择很少,那么这种增加的复杂性可能是浪费的。
- 对于小类,策略模式可能会使代码的可读性略微降低。然而,随着复杂性的增加,以这种方式分解类可以提高可读性。
拦截过滤器模式
拦截过滤器模式用于在客户端和服务之间向请求或响应注入预处理和后处理。在将请求传递给最终服务之前,可以以任意顺序定义并应用任意数量的过滤器到请求。例如,日志记录和身份验证服务是经常有用的过滤器,只需实现一次即可普遍应用。测试过滤器可以以这种方式插入,而不会干扰系统中的任何其他处理。
好处:
- 与策略模式一样,这种模式通过不将所有的预处理和后处理逻辑都放在类中,使类更简单。
- 使用拦截过滤器可以成为重用的强大动力,并能显著减少代码库的规模。
权衡:
- 如果有大量数据被传递到服务,这种模式可能效率非常低,并且会增加相当大的延迟,因为每个过滤器都要对整个输入进行完整的遍历。
12.5 扩展阅读
关于软件测试的文献多到能让一艘战舰沉没,但从架构的角度探讨如何让您的系统更具可测试性的著述则相对较少。关于测试的良好概述,请参阅 [Binder 00] 。杰夫·沃斯(Jeff Voas)关于可测试性以及可测试性与可靠性之间关系的基础性工作也值得研究。有好几篇论文可供选择,但 [Voas 95] 是一个不错的起点,它会为您指引其他相关内容。
贝托利诺(Bertolino)和斯特里吉尼(Strigini)[Bertolino 96a,96b] 是 图 12.1 中所示测试模型的开发者。
“鲍勃大叔”马丁(“Uncle Bob” Martin)撰写了大量关于测试驱动开发以及架构和测试之间关系的内容。关于这方面最好的书是罗伯特·C·马丁(Robert C. Martin)的《整洁架构:软件结构与设计工匠指南》[Martin 17]。肯特·贝克(Kent Beck)撰写的关于测试驱动开发的早期权威参考书籍是《测试驱动开发:示例》[Beck 02]。
传播成本耦合度量标准最初在[MacCormack 06]中被描述。解耦级别度量标准在[Mo 16]中被描述。
模型检查是一种象征性地执行所有可能代码路径的技术。使用模型检查能够验证的系统规模是有限的,但设备驱动程序和微内核已成功进行了模型检查。有关模型检查工具的列表,请访问 https://en.wikipedia.org/wiki/Model_checking 。
12.6 问题讨论
1. 一个可测试的系统是容易暴露其错误的系统。也就是说,如果一个系统包含错误,那么不需要花费很长时间或很大努力就能让该错误显现出来。相比之下,容错性完全是关于设计极力隐藏其错误的系统;在这种情况下,整个理念是让系统很难暴露其错误。是否有可能设计一个既高度可测试又高度容错的系统,或者这两个设计目标本质上是不相容的?请讨论。
2. 您认为可测试性与其他哪些质量属性冲突最大?您认为可测试性与其他哪些质量属性最兼容?
3. 许多用于提高可测试性的策略对于实现可修改性也很有用。您认为这是为什么?
4. 为基于 GPS 的导航应用程序编写一些具体的可测试性场景。您会在设计中采用哪些策略来应对这些场景?
5. 我们的策略之一是限制不确定性,其中一种方法是使用锁来强制同步。使用锁对其他质量属性有什么影响?
6. 假设您正在构建下一个出色的社交网络系统。您预计在首次亮相后的一个月内,您将拥有 50 万用户。您无法支付 50 万人来测试您的系统,但当所有 50 万人都在使用它时,它必须强大且易于使用。您应该怎么做?哪些策略会对您有帮助?为这个社交网络系统编写一个可测试性场景。
7. 假设您使用可执行断言来提高可测试性。请提出支持和反对在生产系统中运行断言(而不是在测试后删除它们)的理由。