深入浅出谈软件的 “可测试性”

核心观点

  1. 软件可测试性是实现高质量、高效率交付的基础,关注可测试性可以提升软件质量。
  2. 可测试性差,会直接增加测试成本,让测试的结果验证变得困难,让测试活动延迟发生。
  3. 可测试性是设计出来的,提升可测试性可以节省研发成本。
  4. 可测试性包括可控制性、可观测性、可追踪性与可理解性四个维度。

随着云原生技术的加速普及与快速发展,软件系统的规模和复杂性不断水涨船高。与此相对应,在软件研发过程中,为测试而设计(Design for testing),为部署而设计(Design for deployment),为监控而设计(Design for monitor),为扩展而设计(Design for scale)和为失效而设计(Design for failure)正在变得越来越重要,甚至成为了衡量软件组织核心研发能力的主要标尺。

本文重点探讨 “为测试而设计” 的理念,以软件可测试性(Testability)作为主线,为大家阐述软件可测试性的方方面面以及软件组织在这个方向上的一些最佳实践与探索。

软件可测试性对软件研发和质量保障有着至关重要的作用,是实现高质量、高效率交付的基础。可测试性差,会直接增加测试成本,让测试的结果验证变得困难,进而会让工程师不愿意做测试,或者让测试活动延迟发生,这些都违背了 “持续测试,尽早以低成本发现问题” 的原则。为此我们有必要对可测试性进行一次深入浅出的探讨,主要内容包含以下 5 个方面

1. 可测试性的定义;

2. 可测试性差引发的问题;

3. 可测试性的三个核心观点;

4. 可测试性的四个维度;

5. 不同级别的可测试性与工程实践。

软件的可测试性是指在一定时间和成本前提下,进行测试设计、测试执行以此来发现软件的问题,以及发现故障并隔离、定位其故障的能力特点。各种组织对可测试性有不同的定义(如图 1),我认为其本质是相通的,都是在说一个软件系统能够被测试的难易程度,或者是说软件系统可以被确认的能力。

我个人比较喜欢的定义是来自 James Bach 的版本:“可测试性就是一个计算机程序能够被测试的容易程度”。

图1:可测试性的各种定义

测试设计能力,暨创造性地设想各种可能性,并设计相应场景是每个软件测试人员的核心技能,但是如何根据测试设计构造出所需要的测试条件,如何高效执行测试,以及测试执行过程中如何对结果进行实时的观察和验证,则是可测试性需要解决的问题。

| GUI 测试层面

➤登录场景下的图片验证码

图片验证码虽然不影响手工测试,但是对自动化测试的可测试性就很不友好,用 OCR 技术识别图片验证码不够稳定,如果能够实现稳定识别反而说明验证码机制有问题。如果登录实现不了自动化就会影响很多其他的自动化测试场景。对于登录过程中的短信验证码也有类似的可测试性问题。

➤页面控件没有统一且稳定的 ID 标识

如果页面控件没有统一并且稳定(不随版本发布而变化)的 ID,自动化测试脚本中控件识别的稳定性就会大打折扣,虽然测试脚本可以通过组合属性、模糊识别等技术手段来提升识别的稳定性,但是测试的成本就会变高。

➤ 非标准控件的识别

非标准的前端页面控件无法通过 GUI 自动化测试识别,这个常见于 Client 端的测试。

➤ 需要对图片格式的输出进行验证

图片的验证缺乏有效的工具支持,即使通过像素对比方案其稳定性也很差。

➤ 业务流程过长难以分解

业务流程和业务场景过长,很难拆解后进行局部的验证。

➤ 不可控的随机弹窗

有些应用会有随机弹窗的功能,比如弹窗广告,或者用户满意度调查等,这类不可控的弹窗会直接影响自动化测试的可测试性。

| 接口测试层面

➤接口测试缺乏详细的设计文档

接口测试如果没有设计契约文档作为衡量测试结果的依据,就会造成测试沟通成本高,无法有效开展结果验证,开发和测试来回扯皮的尴尬窘境。即使有了文档,还必须保持文档能够及时更新,否则会造成误导。

➤ 构建 Mock 服务的成本过高

微服务架构下,如果构建 Mock 服务的难度和成本过高,会直接造成不可测或者测试成本过高。

➤ 接口调用的结果验证困难

接口成功调用后,判断接口行为是否符合预期的验证点难以获取。

➤ 接口调用不具有幂等性

接口内部处理逻辑依赖有未决因素,比如时间、不可控输入、后台批处理 job、随机变量等,破坏接口调用的幂等性。

➤ 接口参数设计过于复杂,暴露了很多不必要的参数

很多内部参数不应该在接口参数上暴露出来,这些参数应该做到无感知,需要保持接口设计的简单性。

➤使用定制化的私有协议

非标的私有化协议会提升测试的难度,通用类的工具无法直接使用。

| 代码层面

➤ 私有函数的调用

在代码级测试中,私有函数无法直接调用。

➤ 私有变量的访问

私有变量缺乏访问手段,以至于无法进行结果验证。

➤ 函数功能的多样性

一个函数如果颗粒度太大,同时实现了好几个功能,会大大提升测试的难度,一来这是因为功能多必然入参也多,测试的时候参数初始化难度就会变大,二来结果验证的关注点也会同时变多,容易出现更多的组合验证,严重的时候会出现组合爆炸。

➤ 代码依赖关系复杂

被测代码中依赖了外部系统或者不可控组件,比如,需要依赖第三方服务、网络通信、数据库等。

➤ 代码可读性差

代码使用 “奇技淫巧”,造成可读性差,同时又缺乏必要的注释说明。

➤重复代码多

重复代码意味着重复逻辑,如果有改动,各个重复逻辑都需要被测试到,测试成本高。

➤ 代码的圈复杂度(Cyclomatic Complexity)过高

圈复杂度过高的代码往往测试成本很高。

➤设计上钩子和注入点缺失

没有预留钩子或者注入点,后期调试和定位问题的扩展能力变差。

| 通用层面

➤ 系统错误较多

被测系统的错误如果比较多,那么就会阻碍后续测试的执行,很多隐藏的问题就没有办法及时暴露,直接影响可测试性。

➤ 无法获取软件运行时的内部信息

测试执行过程中,有些结果的验证需要获取软件内部的信息进行比对,如果无法通过低成本的手段获取信息,测试的验证成本就会很高。

➤ 复杂测试数据的构建

很多测试设计都依赖于特定的测试数据,如果多样性的测试数据构建比较困难,也会直接影响系统的可测试性。

➤ 无法获取系统运行时的实时配置

无法获取实时配置就意味着无法重建测试环境用于问题的重现和定位,增加了测试的难度与不确定性。

➤ 压测场景下的性能 Profiling

很多性能问题只能在高负载场景下才能重现,但是在高负载场景下无法通过日志的方式来获取系统性能数据,因为一旦提高了日志等级,日志输出本身就会成为系统瓶颈,进而把原来的性能问题掩盖掉了。

可以看到,可测试性问题不仅出现在端到端的功能测试层面,在接口测试和代码级测试层面都有可测试性问题,而且可测试性对于自动化测试的实现成本也很关键。

类似的例子还能举出很多,比如不受控制的触发条件、由时间触发的逻辑、难易获取的条件、调用链路获取和大量外部系统依赖等等,这里限于篇幅就不再一一展开了。

在正式讨论可测试性的技术性细节之前,很有必要先把可测试性的核心观点先和大家对齐。我认为可测试性有三个核心观点(图 2)。

图2:可测试性的3个核心观点

| 可测试性是设计出来的

毋容置疑,可测试性不是与生俱来的,而是被设计和实现出来的。可测试性必须被明确地设计,并且正式纳入需求管理的范畴。在研发团队内,测试架构师应该牵头推动可测试性的建设,并和软件架构师、开发工程师和测试工程师达成一致。测试工程师和测试架构师应该是可测试性需求的提出者,并且负责可测试性方案的评估和确认。在研发过程中,可测试性的评估要尽早开始,一般始于需求分析和设计阶段,并贯穿研发全流程,所以可测试性不再是测试工程师的责任,而是整个研发团队的职责。

| 提升可测试性可以节省研发成本

良好的可测试性意味着测试的时间成本和技术成本都会降低,同时还能提升自动化测试的可靠性与稳定性,降低自动化测试的成本。今天在可测试性上的前期投资,会带来后续测试成本的大幅度降低。今天多花的一块钱可以为将来节省十块钱,这点再次证明了 “很多时候选择比努力更重要” 这一观点。

| 关注可测试性可以提升软件质量

可测试性好的软件必然拥有高内聚、低耦合、接口定义明确、行为意图清晰的设计。在准备写新代码时,要问自己一些问题:“我将如何测试我的代码?我将如何在尽量不考虑运行环境因素的前提下编写自动化测试用例来验证代码的正确性?” 如果你无法回答好这些问题,那么请重新设计你的接口和代码。当你在开发软件时,时常问自己 “我将如何验证软件的行为是否符合预期”,并且愿意为了达成这个目标对软件进行良好的设计,作为回报,你将得到一个具有良好结构的系统。

最后,我想说的是 “质量是奢侈品,可测试性更是奢侈品中的奢侈品”。要让研发团队重视可测试性是件很难的事情。究其根本原因是因为研发团队 “不够痛”。

长久以来,测试和开发一直是分开的两个团队,开发工程师往往更关注功能的实现,充其量会去关注一些类似性能、安全和兼容性相关的非功能需求,对于可测试性基本是没有任何优先级的,因为测试工作并不是由开发工程师自己完成的,可测试性的价值开发工程师根本就感受不到。而测试工程师虽然饱受可测试性的各种折磨,可是又苦于在软件研发生命周期的下游,对此也无能为力,因为很多可测试性需求是需要在设计阶段就考虑并实现的,到了最后的测试阶段很多事情已经为时已晚。

很多时候,你不想改是因为你不痛,你不愿改是因为你不够痛,只有真正痛过才知道改的价值。所以应该让让开发工程师自己承担测试工作,这样开发工程师会切身的感受到可测试性的重要性与价值,进而在设计与实现阶段赋予系统更优秀的可测试性,由此而来的良性循环能让系统整体可测性始终处于较高水平。这其实也是开发者自测能够带了的一个好处。关于开发者自测的话题,可以关注我之前在 InfoQ 上写的一篇热文 《开发要不要自己做测试?怎么做?》。

可测试性的分类方法很多不同的版本。比如由 James Bach 提出的 “实际可测试性” 模型(Heuristics of Software Testability)(图 3),由 Microsoft 提出的 SOCK 可测试性模型(图 4),由 Siemens 提出的 “可测试性设计检查表” 模型等。

图3:由James Bach提出的“实际可测试性”模型

(Heuristics of Software Testability)

图4:由Microsoft提出的SOCK可测试性模型

虽然各种不用分类方法的切入点不尽相同,但是其本质却是相通的。在这些模型的基础上,我做了一些归纳和总结,将其定义成可控制性、可观测性、可追踪性与可理解性四个维度(图 5)。下面我们依次展开讨论。

图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的执行溯源;
  • 打通前端和后端的调用链路,实现后端流量可溯源;
  • 实现数据库和缓存类组件的数据流量可溯源;
  • 确保以上信息的保留的时长,便于以 “周” 或 “月” 为频次发生的异常分析;
  • ...

在云原生时代,综合集成了日志(Logging)、链路追踪(Tracing)和度量指标(Metrics)的 OpenTelemetry 是这个领域的主要发展方向,OpenTelemetry 旨在将Logging、Tracing 和 Metrics 三者进行统一,实现数据的互通互操作,改变各自为政、信息孤岛的问题。

| 可理解性

可理解性是指被测系统的信息获取是否容易,信息本身是否完备,并且易于理解。比如被测对象是否有说明文档,并且文档本身可读性以及及时性都有保证。常见的可理解性包含以下这些方面:

  • 提供用户文档(使用手册等)、工程师文档(设计文档等)、程序资源(源代码、代码注释等)以及质量信息(测试报告等);
  • 文档、流程、代码、注释、提示信息易于理解;
  • 被测对象是否有单一且清楚定义的任务,体现出关注点分离;
  • 被测对象的行为是否可以进行具有确定性的推导与预测;
  • 被测对象的设计模式能够被很好地理解,并且遵循行业通用规范;

不同级别有不同的可测试性要求。下面我们分别从代码级别、服务级别和业务需求级别来分别展开讨论。

1.代码级别的可测试性

代码级别的可测试性是指针对代码编写单元测试的难易程度。对于一段被测代码,如果为其编写单元测试的难度很大,需要依赖很多 “奇技淫巧” 或者单元测试框架和 Mock 框架的高级特性,那往往就意味着代码实现得不够合理,代码的可测试性不好。如果你是资深的开发工程师,并且一直有写单元测试的习惯,你会发现写单元测试本身其实并不难,反倒是写出可测试性好的代码却是一件非常有挑战的事情。

| 代码违反可测试性的反模式有很多,常见的有以下这些:

  • 无法 Mock 依赖的组件或服务;
  •  代码中包含未决行为逻辑;
  •  滥用可变全局变量;
  •  滥用静态方法;
  •  使用复杂的继承关系;
  •  高度耦合的代码;
  •  I/O 和计算不解耦。

为了便于理解,我们用 “无法 Mock 依赖的组件或服务” 给大家展开举个例子,以便大家更好地理解什么是代码级别的可测试性。

下面是示例的被测代码(图 7),其中 Transaction 类是经过抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况,Transaction 类中的 execute() 函数负责执行转账操作,将钱从买家的钱包转到卖家的钱包中,真正的转账操作是通过 execute() 函数中调用 WalletRpcService RPC 服务来完成的。

图7:被测代码:Transaction类

现在为其编写单元测试如下(图 8)。

图8:Transaction类中execute()函数的单元测试

这个单元测试的代码本身很容易理解,无外乎就是提供参数调用一下 execute 函数,但是如果要让这个单元测试能够顺利运行,还需部署 WalletRpcService 服务,一来搭建和维护的成本比较高,二来还需要确保将伪造的 transaction 数据发送给 WalletRpcService 服务之后,能够正确返回我们期望的结果以完成不同路径的测试覆盖,而且测试的执行需要走网络,耗时也会比较长,网络的中断、超时、WalletRpcService 服务的不可用,都会直接影响单元测试的执行,所以在严格意义上来讲,这样的测试已经不属于单元测试的范畴了,更像是集成测试。所以我们需要用 Mock 来实现依赖的解耦,用一个 “假” 的服务替换真正的服务,而且这 “假” 的 Mock 服务需要完全在我们的控制之下,模拟输出我们想要的数据,以便控制测试的执行路径。

为此,我们可以轻松构建出以下的 Mock(图 9),通过继承 WalletRpcService 类,并且重写其中的 moveMoney() 函数的方式来实现。这样就可以让 moveMoney() 返回任意我们想要的数据,并且不需要真正进行网络通信。

图9:WalletRpcService的Mock

但是接下来试图用 MockWalletRpcServiceOne 和 MockWalletRpcServiceTwo 来替换代码中的真正的 WalletRpcService 的时候,你会发现因为 WalletRpcService 是在 execute() 函数中通过 new 的方式创建的(图 7中的第 6 行代码),我们无法动态地对其进行替换,这就是典型的代码可测试性问题。

为了能够解决这个问题,需要对代码进行适当的重构,这里会使用依赖注入的方式,依赖注入是实现代码可测试性的最有效的手段,可以将 WalletRpcService 对象的创建反转给上层逻辑,在外部创建好之后,再注入到 Transaction 类中。具体的代码实现如下(图 10)。

图10:用依赖注入解决代码可测试性问题

这样单元测试中可以非容易地将 WalletRpcService 替换成 Mock 出来的 MockWalletRpcServiceOne 或 WalletRpcServiceTwo了(图 11)。

图11:重构以后的单元测试

以上就是用依赖注入实现 Mock,解决可测试性难题的实际案例了。在代码级别的可测试性上,Google 早期有过一个不错的实践,建了一套工具 Testability Explorer 专门对代码的可测试性进行综合性的评价并给出分析报告(图 12),有点类似于代码静态检查的思路,可惜目前 Testability Explorer 已经没有再继续维护了,详细的信息可以参考

https://testing.googleblog.com/2008/10/testability-explorer-measuring.html。

图12:Google Testability Explorer的报告

2.服务级别的可测试性

服务级别的可测试性主要是针对微服务来讲的。相对代码级别的可测试性,服务级别的可测试性更容易理解。一般来讲,服务级别的可测试性主要考虑以下这些方面:

  • 接口设计的契约化程度;
  • 接口设计文档的详细程度;
  • 私有协议的详细设计;
  • 服务运行的可隔离性;
  • 服务扇入扇出的大小;
  • 服务部署的难易程度;
  • 服务配置信息获取的难易程度;
  • 服务内部状态的可控制性;
  • 测试数据构造难易程度;
  • 服务输出结果验证的难易程度;
  • 服务后向兼容性验证的难易程度;
  • 服务契约获取与聚合的难易程度;
  • 服务资源占用的可观测性;
  • 内部异常模拟的难易程度;
  • 外部异常模拟的难易程度;
  • 服务调用链路追踪的难易程度;
  • 内置测试(BIST)的实现程度;
  • ...

3.业务需求级别的可测试性

业务级别的可测试性是最容易理解,也是平时大家接触最多的。一般来讲,业务级别的可测试性可以进一步细分为手工测试的可测试性和自动化测试的可测试性。业务需求级别的可测试性有以下典型的场景:

  • 登录过程中的图片验证码或者短信验证码;
  • 硬件U盾/USB Key;
  • 触屏应用的自动化测试设计;
  • 第三方系统的依赖与模拟;
  • 业务测试流量的隔离;
  • 系统的不确定性弹框;
  • 非回显结果的验证;
  • 可测试性与安全性的平衡;
  • 业务测试的分段执行;
  • 业务测试数据的构造;
  • ... 

总结:

本文系统性探讨了可测试性的定义,谈了可测试性差引发的问题,给出了可测试性的三个核心观点和四个维度。最后从代码级别、服务级别和业务需求级别探讨了可测试性的实例与关注点。

文章来源于茹炳晟聊软件研发 ,作者茹炳晟

#极狐Gitlab##技术栈##测试##开发##研发#
全部评论
这些知识都进脑就好了
点赞 回复 分享
发布于 2023-03-25 11:54 四川
果然我要学习的东西还有很多
点赞 回复 分享
发布于 2023-03-25 12:33 湖南

相关推荐

10-30 10:16
南京大学 Java
龚至诚:给南大✌️跪了
点赞 评论 收藏
分享
Natrium_:这时间我以为飞机票
点赞 评论 收藏
分享
点赞 6 评论
分享
牛客网
牛客企业服务