可测试性(Testability)是指软件系统或组件或程序片段的属性能够被验证的程度,即一个软件系统能够被测试的难易程度或被确认的能力。理论上,对于任何一个软件,总能找到办法对其进行测试验证,但只有那些具有良好可测试性的软件,才可能得到高效、完备的测试。一个函数如果粒度太大,多功能带来多入参问题,必然产生多组合验证等问题,显著增加测试初始化难度及测试生命周期成本。微服务架构下,如果构建Mock服务的难度和成本过高,会直接造成不可测或测试成本过高等。
基于云原生等技术,软件系统的规模及复杂性与日俱增,如果被测系统缺乏良好的可测试性,软件测试将愈发困难,传统软件测试方法亦将受到前所未有的挑战。软件开发,为测试而设计、为部署而设计、为监控而设计、为扩展而设计、为失效而设计。这是认识和理念的根本转变。良好的可测试性让软件缺陷无处遁形,有利于软件测试工作有效开展,有利于降低质量风险和测试成本。
软件可测试性包括需求、架构、设计、代码、数据等的可测试性。基于设计方法学的软件设计,基于编码规则的编码实现,是保证可测试性的基础,简化软件设计和编码,是改进可测试性的重要手段。软件可测试性生命周期过程模型如图2-9所示。
图2-9 软件可测试性生命周期过程模型
可测试性是软件的通用质量特性之一,与开发同策划、同设计、同验证、同改进。需求分析阶段,在业务层面对每个用户故事建立验收标准,在功能层面基于用户要求,确定测试性需求;软件设计阶段,从架构设计、接口设计、日志设计等,建立可测试性规范,采用良好的设计模式,遵守高内聚低耦合、面向对象的SOLID等设计原则,为测试提供额外的接口;编码实现阶段,为可测试性设计代码规范,确保代码具有良好的可测试性。
例如,避免在构造函数中引入业务逻辑,如实例化和初始化协作对象、调用静态方法及复杂赋值逻辑等。在一个类中,只传入所需对象作为参数,避免在传入对象中进一步挖掘依赖对象。图2-10所示反例和正例,清晰地说明了软件的可测试性问题。
图2-10 一个软件可测试性的反例和正例
可测试性主要通过可控制性、可观测性、可追踪性及可理解性等要素来表征,当然,在特定的场景中,预见性、简单性、稳定性也是表征软件可测试性的重要特征。软件可测试性特征及其维度如图2-11所示。
例如,某软件系统由两个不具备可观测性和可控制性的单元A和B构成,这两个软件单元及软件系统不具备可测试性。那么,如何改善这两个软件单元及软件系统的可测试性呢?显然,只要在单元A和B之间增加一个接口,使单元A的输出(状态信息)能够在该接口被观测到,同时通过接口能够向单元B输入数据,就能够对单元B进行控制。通过如此处理之后,由单元A传递给单元B的数据就可以被观测和控制,且具有可预见性,从而使得该软件系统具有满足需求的可测试性。图2-12展示了该软件系统可测试性的改进方法。
图2-11 软件可测试性特征及其维度
图2-12 软件系统可测试性改进方法示例
2.3.2.1 可控制性
可控制性是指在确定的条件下,在配置空间操作或改变系统的能力,包括状态控制、输入输出控制。可控制性一般包括如下四个层面。
(1)业务层面:业务流程以及业务场景易于分解,可实现分段控制与验证。对于复杂业务流程,则需要合理地设定分解点,确保测试过程中能够对其进行有效分解或切片。
(2)架构层面:采用模块化设计,各模块支持独立部署和测试,具有良好的独立性和可隔离性,便于构造Mock环境,模拟依赖关系。
(3)数据层面:测试数据具有良好的可控制性,以便构建多样性测试数据,满足不同的测试场景需求。
(4)实现层面:可控制性的技术实现手段涉及多个方面。例如,在系统外部直接或间接控制系统状态及变量,方便的接口调用,运行时的可注入能力,私有函数及内部变量的外部访问能力,轻量级的插桩能力,面向切面编程(Aspect Oriented Programming,AOP)等技术,实现预期的可控制性。
2.3.2.2 可观测性
可观测性是指在确定的条件下和时间内,基于输出描述系统状态的能力,即通过外部获取系统内部状态信息,评估其状态能力的难易程度。对于一组操作或输入,系统产生预期、明确及可视的响应或输出。所谓“可视”是指运行时及过程可视,在时间维度上,还包括当前和过去的可视,而且是可查询的。输出是可视的基础。工程上,通常采用输入、输出的个数之比(Domain/Range Ratio,DRR)度量信息的丢失程度,DRR值越大,说明信息丢失越多,错误隐藏越多,可测试性越差。在输入个数不变的情况下,输出参数越多,就能够获取更多的信息,发现更多的错误,系统的可测试性就越好。
通过分级事件日志、调用链路追踪信息及各种聚合度量指标,识别输出,基于可测试性接口获取系统内部自检上报信息,确保影响软件行为的因素可视,提高可观测性。在云原生环境下,OpenTelemetry将事件日志、追踪信息及度量指标统一起来,实现数据互通及互操作,较好地解决了信息孤岛问题。
工程上,通常使用日志、度量、追踪等输出,评价系统的可观测性。日志是有时间戳、不可改变的离散事件记录,用于识别系统中不可预测的行为,洞察软件系统发生错误时的行为及变化,一般以结构化方式读取日志,如JSON格式,便于日志查询和自动索引。度量是监控的基础,会在一段时间内汇总并提供度量结果。在分布式系统中,当一个单独的事务或请求从一个节点移动至另一个节点时,追踪允许深入了解特定请求的细节,以确定哪些组件会导致系统错误,如监测通过模块的流量,找到性能瓶颈。
监控是可观测性的一部分,可观测性是监控的超集。两者的主要区别在于主动发现问题的能力。主动发现问题是可观测性的关键。可观测性从“被动监控”向“主动发现与分析”方向发展。可观测能力可划分为告警、应用概览、排错、剖析和依赖分析5个层级。告警与应用概览属于传统监控范畴。对于触发告警,往往具有明显的症状及表象,随着系统架构愈发复杂以及应用向云原生部署方式转变,没产生告警并不能说明系统不存在问题。因此,对于系统内部信息的获取与分析尤为重要。这部分能力主要体现在排错、剖析和依赖分析,这三者体现了“主动发现与分析”能力,逐层递进。
无论是否触发告警,基于主动发现能力,能够对系统运行状态进行诊断,通过指标呈现系统运行的实时状态。一旦发现异常,逐层下钻定位问题,必要时进行性能分析,有利于基于数据分析,增进对系统的认识,预测和防范故障发生。调取模块与模块之间的交互状态,通过链路追踪构建整个系统的“上帝视角”。图2-13给出了系统可观测性与监控的关系。
图2-13 系统可观测性与监控的关系
可观测性对系统的可控制性具有重要影响,系统状态信息对于系统的可测试性具有决定性的作用。但如果无法获得准确的状态信息,就无法判断在下一步是否需要进行控制变更。如果不能对状态及变更进行有效控制,可控制性就无从谈起。可观测性与可控制性相辅相成,缺一不可。DevSecOps的基础就是监控和可观测性。可观测性是一个聚合系统产生的所有数据的解决方案,而监控则是一种收集和分析从单个系统中提取的预定数据的解决方案。监控只显示数据,可观测性则可以借助基础设施度量多个应用程序、微服务、服务器以及数据库的所有输入和输出,支撑系统的健康监测。
2.3.2.3 可追踪性
可追踪性是指跟踪系统行为、事件、操作、状态、性能、错误以及调用链路的能力,主要包括如下几个方面:
(1)记录并持续更新全局逻辑架构视图与物理部署视图。
(2)跟踪记录服务端模块间的全量调用链路、调用频次及性能数据。
(3)跟踪记录关键流程的函数执行过程、输入输出参数、持续时间及扇入扇出信息。
(4)跟踪记录跑批类作业的执行溯源。
(5)打通前端和后端调用链路,确保后端流量的可溯源性。
(6)实现数据库和缓存类组件的数据流量可溯源。
(7)以确定的周期为频次进行异常分析。
2.3.2.4 可理解性
可理解性是指获取软件系统信息并予以理解的能力,包括信息获取的难易程度以及信息本身的完备性、易理解性。其包含但不限于如下内容:
(1)任务定义完整、准确,关注点分离。
(2)系统行为可以进行确定性推导及预测。
(3)设计模式遵循行业通用规范,能够被很好地理解。
(4)文档、流程、代码、数据等信息齐套、完整、准确,易于理解。
2.3.3.1 代码级别的可测试性
代码级别的可测试性,通常用于度量单元测试的难易程度。对于一段代码,如果需要依赖测试框架和Mock框架的高级特性,或奇技淫巧,才能完成测试,则意味着该代码的可测试性较差。编写具有良好可测试性的代码并非易事,违反可测试性的反模式不胜枚举。比如,无法Mock依赖的组件或服务、代码中包含未决行为逻辑、滥用可变全局变量、滥用静态方法、使用复杂的继承关系、高度耦合的代码、I/O和计算不解耦,等等。而那些随心所欲的注释、莫名其妙的链接,如果再“下点毒”,测试人员不“吐血”才怪!
除编程技能外,良心、规范是确保代码可测试性的基础。为了便于理解代码级别的可测试性,下面以“无法Mock依赖的组件或服务”为例进行说明。
上述Transaction类是经过抽象简化的一个电商系统交易类,用以记录每笔订单的交易情况,类中的execute()函数实现转账操作,将交易费用从买家转入卖家,通过Execute()函数调用WalletRpcService RPC服务完成转账操作。对此,编写如下测试代码,通过提供参数调用Execute()函数,实现上述转账服务测试。
该测试代码提供参数调用Execute()函数,但为了使得该测试能够顺利运行,需要部署WalletRpcService服务,但搭建和维护成本较高,且需要确保将构造的transaction数据发送给WalletRpcService服务之后,返回期望结果以完成不同路径覆盖。基于网络的测试执行,耗时较长,网络中断、超时以及WalletRpcService服务不可用等情况都会影响测试执行,需要用Mock实现依赖解耦,即用一个“假”服务替换“真”服务,模拟输出所需数据,以便控制测试执行路径。因此,构建如下Mock,通过继承WalletRpcService类,重写moveMoney()函数,就可以让moveMoney()返回任意想要得到的数据,而无须进行网络通信。
接下来,如果用MockWalletRpcServiceOne和MockWalletRpcServiceTwo代替代码中的WalletRpcService,就会发现WalletRpcService是在execute()函数中通过new方式创建,无法动态地对其替换,这就是典型的代码测试性问题。
为了能够有效地解决该问题,可通过依赖注入方式,对代码进行适当重构,将WalletRpcService对象的创建反转给上层逻辑,在外部创建完成之后,将其注入到Transaction类中。重构后的测试代码如下:
2.3.3.2 服务级别的可测试性
在服务级别,基于服务架构,可测试性包括接口设计文档的详细程度、接口设计的契约化程度、私有协议设计的详细程度、服务内部状态的可控制性、服务运行的可隔离性、服务扇入扇出大小、服务资源占用的可观测性、内置测试(Built-In Test,BIT)的实现程度以及服务部署、服务配置信息获取、测试数据构造、服务输出结果验证、服务后向兼容性验证、服务契约获取与聚合、内部异常模拟、外部异常模拟、服务调用链路追踪等的难易程度。
2.3.3.3 业务需求级别的可测试性
业务需求级别的可测试性可划分为人工及自动化测试的可测试性,通常包括登录过程中的图片或短信验证码、硬件U盾/USB Key、触屏应用的自动化测试设计、第三方系统的依赖与模拟、业务测试流量隔离、系统不确定性弹框、非回显结果验证、可测试性与安全性平衡、业务测试的分段执行、业务测试数据构造等典型场景。