深入浅出谈软件的“可测试性”
核心观点
软件可测试性是实现高质量、高效率交付的基础,关注可测试性可以提升软件质量。
可测试性差,会直接增加测试成本,让测试的结果验证变得困难,让测试活动延迟发生。
可测试性是设计出来的,提升可测试性可以节省研发成本。
可测试性包括可控制性、可观测性、可追踪性与可理解性四个维度。
随着云原生技术的加速普及与快速发展,软件系统的规模和复杂性不断水涨船高。与此相对应,在软件研发过程中,为测试而设计(Design for testing),为部署而设计(Design for deployment),为监控而设计(Design for monitor),为扩展而设计(Design for scale)和为失效而设计(Design for failure)正在变得越来越重要,甚至成为了衡量软件组织核心研发能力的主要标尺。
本文重点探讨“为测试而设计”的理念,以软件可测试性(Testability)作为主线,为大家阐述软件可测试性的方方面面以及软件组织在这个方向上的一些最佳实践与探索。
软件可测试性对软件研发和质量保障有着至关重要的作用,是实现高质量、高效率交付的基础。可测试性差,会直接增加测试成本,让测试的结果验证变得困难,进而会让工程师不愿意做测试,或者让测试活动延迟发生,这些都违背了“持续测试,尽早以低成本发现问题”的原则。为此我们有必要对可测试性进行一次深入浅出的探讨,主要内容包含以下5个方面。
可测试性的定义
可测试性差引发的问题
可测试性的三个核心观点
可测试性的四个维度
不同级别的可测试性与工程实践
01
可测试性的定义
软件的可测试性是指在一定时间和成本前提下,进行测试设计、测试执行以此来发现软件的问题,以及发现故障并隔离、定位其故障的能力特点。各种组织对可测试性有不同的定义(如图1),我认为其本质是相通的,都是在说一个软件系统能够被测试的难易程度,或者是说软件系统可以被确认的能力。
我个人比较喜欢的定义是来自James Bach的版本:“可测试性就是一个计算机程序能够被测试的容易程度”。
图1:可测试性的各种定义
02
可测试性差引发的问题
很多人会觉得可测试性似乎是个新命题,在软件测试发展的很长一段时间里,这个概念似乎并没有被广泛提及。那是因为以前的软件测试是偏粗犷式的黑盒模式,而且测试团队和开发团队是分离,测试工程师往往在研发后期才会介入,测试始终处于被动接受的状态。并且大量的测试与验证都是偏向黑盒功能,所以可测试性的矛盾并没有被凸显出来。但是在今天,随着测试左移,开发者自测,测试与开发融合以及精准测试的广泛普及,这种粗犷式的黑盒验证已经无法满足软件的质量要求。
如果继续忽视可测试性,不从源头上对可测试性予以重视,将会导致研发过程中系统不可测,或者测试成本过高的窘境。可以说,忽视可测试性就是在累积技术债务。更何况,今天大行其道的DevOps全程都离不开测试,测试成为了拉通持续集成与持续发布(CI/CD)各个阶段的“连接器”,如果可测试性不行,整个持续集成与发布的效率也会大受影响。
为了帮助大家更好地理解可测试性,这里先列举一些实际的可测试性问题作为抛砖引玉。
GUI测试层面:
登录场景下的图片验证码:图片验证码虽然不影响手工测试,但是对自动化测试的可测试性就很不友好,用OCR技术识别图片验证码不够稳定,如果能够实现稳定识别反而说明验证码机制有问题。如果登录实现不了自动化就会影响很多其他的自动化测试场景。对于登录过程中的短信验证码也有类似的可测试性问题。
页面控件没有统一且稳定的ID标识:如果页面控件没有统一并且稳定(不随版本发布而变化)的ID,自动化测试脚本中控件识别的稳定性就会大打折扣,虽然测试脚本可以通过组合属性、模糊识别等技术手段来提升识别的稳定性,但是测试的成本就会变高。
非标准控件的识别:非标准的前端页面控件无法通过GUI自动化测试识别,这个常见于Client端的测试。
需要对图片格式的输出进行验证:图片的验证缺乏有效的工具支持,即使通过像素对比方案其稳定性也很差。
业务流程过长难以分解:业务流程和业务场景过长,很难拆解后进行局部的验证。
不可控的随机弹窗:有些应用会有随机弹窗的功能,比如弹窗广告,或者用户满意度调查等,这类不可控的弹窗会直接影响自动化测试的可测试性。
接口测试层面:
接口测试缺乏详细的设计文档:接口测试如果没有设计契约文档作为衡量测试结果的依据,就会造成测试沟通成本高,无法有效开展结果验证,开发和测试来回扯皮的尴尬窘境。即使有了文档,还必须保持文档能够及时更新,否则会造成误导。
构建Mock服务的成本过高:微服务架构下,如果构建Mock服务的难度和成本过高,会直接造成不可测或者测试成本过高。
接口调用的结果验证困难:接口成功调用后,判断接口行为是否符合预期的验证点难以获取。
接口调用不具有幂等性:接口内部处理逻辑依赖有未决因素,比如时间、不可控输入、后台批处理job、随机变量等,破坏接口调用的幂等性。
接口参数设计过于复杂,暴露了很多不必要的参数:很多内部参数不应该在接口参数上暴露出来,这些参数应该做到无感知,需要保持接口设计的简单性。
使用定制化的私有协议:非标的私有化协议会提升测试的难度,通用类的工具无法直接使用。
代码层面:
私有函数的调用:在代码级测试中,私有函数无法直接调用。 私有变量的访问:私有变量缺乏访问手段,以至于无法进行结果验证。 函数功能的多样性:一个函数如果颗粒度太大,同时实现了好几个功能,会大大提升测试的难度,一来这是因为功能多必然入参也多,测试的时候参数初始化难度就会变大,二来结果验证的关注点也会同时变多,容易出现更多的组合验证,严重的时候会出现组合爆炸。 代码依赖关系复杂:被测代码中依赖了外部系统或者不可控组件,比如,需要依赖第三方服务、网络通信、数据库等。 代码可读性差:代码使用“奇技淫巧”,造成可读性差,同时又缺乏必要的注释说明。 重复代码多:重复代码意味着重复逻辑,如果有改动,各个重复逻辑都需要被测试到,测试成本高。 代码的圈复杂度(Cyclomatic Complexity)过高:圈复杂度过高的代码往往测试成本很高。 设计上钩子和注入点缺失:没有预留钩子或者注入点,后期调试和定位问题的扩展能力变差。
通用层面:
系统错误较多:被测系统的错误如果比较多,那么就会阻碍后续测试的执行,很多隐藏的问题就没有办法及时暴露,直接影响可测试性。 无法获取软件运行时的内部信息:测试执行过程中,有些结果的验证需要获取软件内部的信息进行比对,如果无法通过低成本的手段获取信息,测试的验证成本就会很高。 复杂测试数据的构建:很多测试设计都依赖于特定的测试数据,如果多样性的测试数据构建比较困难,也会直接影响系统的可测试性。 无法获取系统运行时的实时配置:无法获取实时配置就意味着无法重建测试环境用于问题的重现和定位,增加了测试的难度与不确定性。 压测场景下的性能Profiling:很多性能问题只能在高负载场景下才能重现,但是在高负载场景下无法通过日志的方式来获取系统性能数据,因为一旦提高了日志等级,日志输出本身就会成为系统瓶颈,进而把原来的性能问题掩盖掉了。
可以看到,可测试性问题不仅出现在端到端的功能测试层面,在接口测试和代码级测试层面都有可测试性问题,而且可测试性对于自动化测试的实现成本也很关键。
类似的例子还能举出很多,比如不受控制的触发条件、由时间触发的逻辑、难易获取的条件、调用链路获取和大量外部系统依赖等等,这里限于篇幅就不再一一展开了。
03
可测试性的三个核心观点
图2:可测试性的3个核心观点
01
可测试性是设计出来的
02
提升可测试性可以节省研发成本
03
关注可测试性可以提升软件质量
04
可测试性的四个维度
图5:可测试性的4个维度
可控制性
在业务层面,业务流程和业务场景应该易分解,尽可能实现分段控制与验证。对于复杂的业务流程需合理设定分解点,在测试时能够对其进行分解。 在架构层面,应采用模块化设计,各模块之间支持独立部署与测试,具有良好的可隔离性,便于构造Mock环境来模拟依赖。 在数据层面,测试数据也需要可控制性,能够低成本构建多样性的测试数据,以满足不同测试场景的要求。 在技术实现层面,可控制性的实现手段涉及很多方面,比如提供适当的手段在系统外部直接或间接的控制系统的状态及变量、在系统外部实现方便的接口调用、私有函数以及内部变量的外部访问能力、运行时的可注入能力、轻量级的插桩能力、使用AOP(Aspect Oriented Programming)面向切面编程技术实现更好的可控制性等。
可观测性
任何一项操作或输入都应该有预期的、明确的响应或输出,而且这个响应或者输出必须是可见,这里的“可见”不仅仅是指运行时可见,还包括维护时可见以及调试时可见,同时在时间维度上还应该包含“当前”和“过去”都“可见”,并且是可查询的,“不可见”和”不可查询“就意味着“不可发现”,可观测性就差,进而影响可测试性。
“可见”的前提是输出,提高可观测性就应该多多输出,包括分级的事件日志(Logging)、调用链路追踪信息(Tracing)、各种聚合指标(Metrics),同时也应该提供各类可测试性接口获取内部信息以及系统内部自检信息的上报,以确保影响程序行为的因素可见。另外,有问题的输出要易于识别,无论通过日志自动分析还是界面高亮显示的方式,要能有助于发现。
关于“多多输出”的理念,我们有一个概念性的指标DRR(Domain/Range Ratio)可以借鉴。DRR可以理解成输入个数和输出个数的比率。DRR用于度量信息的丢失程度。DRR越大,信息越容易丢失,错误越容易隐藏,可测试性也就越低。因此要降低DRR,在输入个数不变的条件下,就要增加输出个数,输出参数越多,能获取的信息越多,也就越容易发现错误。
接下来,谈一下可观测性和监控的关系。监控告诉我们系统的哪些部分不工作了,可观测性告诉我们哪些不工作的部分为什么不工作了,所以我认为监控是可观测性的一部分,可观测性是监控的超集。两者的区别主要体现在问题的主动发现(Preactive)能力这个层面,可以说主动发现是可观测性能力的关键。今天我们在谈的可观测性正在从过去的“被动监控”转向“主动发现与分析”。
通常我们会将可观测性能力划分为5个层级(图6),其中告警(Alerting)与应用概览(Overview)属于传统监控的概念范畴。由于触发告警的往往是明显的症状与表象,但随着系统架构愈发复杂以及应用向云原生部署方式的转变,没有产生告警并不能说明系统一定没有问题,因此,系统内部信息的获取与分析就变得非常重要,这部分能力主要体现在排错(Degugging)、剖析(Profiling)和依赖分析(Dependency Analysis),这三者体现了“主动发现与分析”能力,并且层层递进:
首先,无论是否发生告警,运用主动发现能力都能对系统运行情况进行诊断,通过指标呈现系统运行的实时状态;
其次,一旦发现异常,逐层下钻定位问题,必要时进行性能分析,调取详细信息,建立深入洞察;
再次,调取模块与模块间的交互状态,通过链路追踪构建整个系统的“上帝视角”。
图6:可观测性和监控的关系
可追踪性
记录并持续更新详细的全局逻辑架构视图与物理部署视图 跟踪记录服务端模块间全量调用链路、调用频次、性能数据等 跟踪记录模块内关键流程的函数执行过程、输入输出参数、持续时间、扇入扇出信息 跟踪记录跑批类Job的执行溯源 打通前端和后端的调用链路,实现后端流量可溯源 实现数据库和缓存类组件的数据流量可溯源 确保以上信息的保留的时长,便于以“周”或“月”为频次发生的异常分析 ...
可理解性
提供用户文档(使用手册等)、工程师文档(设计文档等)、程序资源(源代码、代码注释等)以及质量信息(测试报告等) 文档、流程、代码、注释、提示信息易于理解 被测对象是否有单一且清楚定义的任务,体现出关注点分离 被测对象的行为是否可以进行具有确定性的推导与预测 被测对象的设计模式能够被很好地理解,并且遵循行业通用规范 …
05
不同级别的可测试性与工程实践
01
代码级别的可测试性
代码级别的可测试性是指针对代码编写单元测试的难易程度。对于一段被测代码,如果为其编写单元测试的难度很大,需要依赖很多“奇技淫巧”或者单元测试框架和Mock框架的高级特性,那往往就意味着代码实现得不够合理,代码的可测试性不好。如果你是资深的开发工程师,并且一直有写单元测试的习惯,你会发现写单元测试本身其实并不难,反倒是写出可测试性好的代码却是一件非常有挑战的事情。
代码违反可测试性的反模式有很多,常见的有以下这些:
· 代码中包含未决行为逻辑
· 滥用可变全局变量
· 滥用静态方法
· 使用复杂的继承关系
· 高度耦合的代码
· I/O和计算不解耦
图7:被测代码:Transaction类
现在为其编写单元测试如下(图8)。
图8:Transaction类中execute()函数的单元测试
图9:WalletRpcService的Mock
图10:用依赖注入解决代码可测试性问题
图11:重构以后的单元测试
图12:Google Testability Explorer的报告
02
服务级别的可测试性
03
业务需求级别的可测试性
总结
本文系统性探讨了可测试性的定义,谈了可测试性差引发的问题,给出了可测试性的三个核心观点和四个维度。最后从代码级别、服务级别和业务需求级别探讨了可测试性的实例与关注点。扩展阅读
最后再推荐一下我的两本老书《测试工程师全栈技术进阶与实践》、《高效自动化测试平台设计与开发实战》和一本新书《软件研发效能提升之美》。