您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center   Code  
会员   
   
 
     
   
 
 订阅
5个关键问题让单元测试的价值最大化
 
作者:肖世良
 
 
   次浏览      
 2023-8-14 
编辑推荐:
本文讨论的单元测试策略来自于实践中遇到的真实问题,总结出了5个关键策略问题并给出了解决之道。希望对你的学习有帮助。
本文来自于微信公众号阿里云云栖号,由Linda编辑、推荐。

一 背景

关于“什么是单元测试”、“为什么要做单元测试”、“怎么做单元测试”,网络上相关的技术文章汗牛充栋。尽管如此,在推广单元测试的过程,通过与研发同学的交流,我发现大家对单元测试的探讨还是存在薄弱的地方。这个薄弱的地方既不是抽象的单元测试理论,也不是具体的单元测试工具,而是理论与实践结合的单元测试策略。

就像测试策略一样,单元测试策略决定了我们能否把单元测试真正做好(而不是流于形式),并且让单元测试产生的价值最大化(而不是与集成测试做类似甚至重复的事情)。

本文讨论的单元测试策略不是空泛的,而是来自于单元测试实践中遇到的真实问题,即:

用例设计问题

边界测试问题

Mock测试问题

与集成测试的分工问题

度量问题

接下来,我们就来逐一分析这5个问题,并探索这些问题的解决之道。

二 用例设计问题

单元测试做得好不好,根本上不在于用多么先进的测试框架、测试工具,而在于测试用例的有效性,即用例是否覆盖了它应该覆盖的东西。如何设计有效的单测用例?这并不是一个唾手可得的技能。只要留意一下大家日常是怎么设计测试用例的,就知道了。

有的人依靠“直觉”或“经验”,想到什么用例,就测什么用例(可以认为没有测试设计这一环节);有的人盯着代码覆盖率数据,目标是多少就测到多少(为了完成KPI而做单测,达到指标了就万事大吉);还有人直接用工具自动生成用例(而不关心这些用例究竟测了什么,没测什么)。

能不能用这些方法做单测呢?当然能,毕竟这样做可能比完全不做单测要好一些。但是这样能把单测做好吗?未必。

和任何其他类型的测试一样,单测做得好不好,不在于我们是怎么测的(how),而在于我们测了什么(what)。然而,“测什么”,“不测什么”,这是测试设计阶段要解决的问题,单测也不例外。因此,在写单测之前,我们需要认真设计一下测试用例。那么,如何设计单测用例呢?这就要回归到测试的基础理论:黑盒测试与白盒测试。



黑盒测试:将被测代码当作黑盒,基于程序对外提供的功能(包括它的输入、输出、以及输入输出作用关系)设计测试用例。典型方法包括边界值分析、等价类划分、决策树、状态机转换等。

白盒测试:将被测代码当作白盒,基于程序内部的实现结构(包括条件、分支、循环等语句)设计测试用例。典型方法有语句覆盖、分支覆盖、条件覆盖、代码路径覆盖等。

显然,黑盒法和白盒法各有特点。对于单元测试来说,如下图所示,综合运用黑盒测试和白盒测试两种方法进行用例设计,是一种提升用例有效性的办法。



以 Apache 开源的Commons Lang 库中的子串函数StringUtils.substring(String str, int start) 为例,来说明图示方法的核心思路。该函数的源代码如下图所示,给定字符串str和子串起始位置start,这个函数返回对应的子串。

public static String substring(String str, int start{
    if (str == null) {
        return null;
    } else {
        if (start < 0) {
            start += str.length();
        }
        if (start < 0) {
            start = 0;
        }
        return start > str.length() ? "" : str.substring(start);
    }
}



那么,如何运用上述方法设计这个函数的单测用例呢?核心有两点。

首先,利用黑盒测试方法设计用例。

将被测程序当作黑盒,利用输入输出关系设计用例。分析入参特征:字符串str 为 String 类型,可选:“NULL、空串、非空串”,子串起始位置 start 为 int 型,可选:“正数(相对位置从左往右)、0、负数(相对位置从右往左)”。分析入参约束关系:start 可以在 str 范围内、范围外。根据决策表法,枚举入参组合作为测试用例。由于组合情况多,运用等价类划分法精简用例。

设计用例如下:

// 字符串为null
assertEquals(null,  StringUtils.substring(null,   0));
// 字符串为空
assertEquals("",    StringUtils.substring("",     0));
// 字符串非空,且起始位置在字符串开头(从左往右)
assertEquals("abc", StringUtils.substring("abc",  0));
// 字符串非空,且起始位置在字符串结尾(从左往右)
assertEquals("c",   StringUtils.substring("abc",  2));
// 字符串非空,且起始位置超出字符串范围(从左往右)
assertEquals("",    StringUtils.substring("abc",  3));
// 字符串非空,且起始位置在字符串开头(从右往左)
assertEquals("c",   StringUtils.substring("abc"-1));
// 字符串非空,且起始位置在字符串结尾(从右往左)
assertEquals("abc", StringUtils.substring("abc"-3));

 

然后,利用代码覆盖结果增强用例。

以上用例是否覆盖完善呢?基于白盒测试,收集并分析被测代码行、分支、条件覆盖情况。结果发现,源代码第 9 行(start = 0) 未覆盖,反映“字符串非空,且起始位置超出字符串范围(从右往左)”这一场景漏测了,为此增加一个用例:assertEquals("abc", StringUtils.substring("abc", -4)) 进行针对性覆盖。

重复上述过程,直到覆盖完善(100%的覆盖度不是必须的)或对被测代码质量有信心(这种信心建立在知道自己测了什么、没测什么的基础上,是真正的信心,而不是盲目自信)为止。

有人也许会说,这个方法是否过于繁琐、成本太高?事实上,如下图所示,根据二八原则,对于绝大部分逻辑简单的方法,只需要简单设计用例就可以了。只有少数长尾的、逻辑复杂的(特征:代码中分支多、判断条件多、执行路径多)的方法才需要严谨地设计用例。事实上,它们也值得这样测试,因为逻辑复杂往往意味着更高的出错可能性和质量风险。



小结:针对逻辑复杂的代码模块,综合运用黑盒测试和白盒测试方法设计测试用例,从而提升单测的覆盖度和有效性。

三 边界测试问题

软件测试,说一千道一万,它的根本目的是发现软件BUG。投资大师芒格有句名言,“要去鱼多的地方捕鱼”。同理,对于测试来说,我们要去BUG多的地方找BUG。

那么,什么地方BUG多呢?经验告诉我们,边界场景BUG多。这里的边界场景是相对于主干流程(即程序的happy path)而言的,它包含了程序的各种分支(branch)、角落(corner)、边缘(edge)、异常(exceptional)、无效(invalid)场景。那么,如何在边界场景找BUG呢?这就要用到边界测试(boundary testing)方法。

如何开展边界测试?如图所示,边界测试通常有三步:1)寻找边界,2)分析边界值,3)设计边界用例。边界测试的核心在于第1步,即寻找边界。



如何寻找边界呢?有两种方法。一种是黑盒法,从需求中寻找边界。另一种是白盒法,从代码中寻找边界。

举个例子。假设我们有这样一个需求:“实现一个倒计时,展示距离某大型活动开幕的时间,要求如下:1) 超过48小时,展示向上取整天数, 2) 48小时及以内展示时分秒, 3) 活动开幕后,倒计时消失”。

黑盒法:直接从需求中寻找边界。

根据需求描述,我们可以推导出2个边界:1) 活动开始前48小时 2)活动开始后。边界的意义在于将测试空间划分为两个等价分区。对于每一个边界,我们通常需要设计2个用例:

正点用例:on point,刚好处于边界上的点(if条件为True)

偏离点用例:off point,离边界点最近且处于边界外(if条件为False)的点

因此,针对上述2个边界,我们可以设计4个测试用例:

边界1: 活动开始前48小时

用例1: 开始前48小时1秒,预期显示3d

用例2: 开始前48小时,预期显示48h 0m 0s

边界2: 活动开始后

用例3: 开始时,预期显示0h 0m 0s

用例4: 开始后1秒,预期倒计时消失

白盒法:从被测代码中寻找边界。

以下是实现上述倒计时需求的代码示例。

// 略:根据diff时间,计算剩余days、hours、minutes、seconds
if (days >= 2) {
  if ((hours + minutes + seconds) == 0) {
    if (days == 2) {
      text = "48h 0m 0s";
    } else {
      text = days + "d";
    }
  } else {
    text = (days + 1) + "d";
  }
else {
  if (days == 1) {
    hours = hours + 24;
  }
  text = hours + "h " + minutes + "m " + seconds + "s ";
}

 

我们从代码中的分支语句(例如if、for、while等)寻找边界值。这段代码有4个if语句,意味着有4个边界值,因此我们至少需要设计8个用例。在这个例子中,相比黑盒法,白盒法得到的边界更多,测试用例也就更多。我们不枚举所有用例,只列举2个不存在于上述黑盒法的用例:

用例5: 开始前3天,预期显示3d

边界来源:Line 4,if (days == 2)

解读:当倒计时时间超出24h且刚好为整数天时,不需要向上取整+1

用例6: 开始前1天1秒,预期显示24h 0m 1s

边界来源:Line 13,if (days == 1)

解读:当剩余天数为1时,倒计时小时数需要+24

由此可见,白盒法有着它的独特优势:由于代码可见,我们通过分析代码结构,可以找到程序的真实边界,这是黑盒测试所做不到的。因此,从边界测试角度看,白盒测试的覆盖率更高,因而BUG发现能力也更强。当然,这并不意味着黑盒法可以被白盒法完全替代。就像用例设计一样,在进行边界测试时,我们也是需要综合运用黑盒和白盒两种方法。

从边界测试角度,我们还可以发现另外一个有趣的结论。我们推动研发开展单元测试,并不只是为了提升测试效率(相比黑盒的集成测试、系统测试,单元测试有更快的运行速度、更低的排错成本、更及时的质量反馈),更是为了提升测试有效性(相比黑盒测试,单元测试由于代码可见性,有能力去更全面地覆盖真实存在的、容易隐藏BUG的各种边界场景)。

四 Mock 测试问题

相比集成测试或系统测试,单元测试的一个重要特点是非常依赖Mock。所谓Mock,就是用模拟对象替换被测代码的依赖,它本质上是测试环境的一部分。

对于集成测试或系统测试,测试环境通常是真实的,并且不同用例共享同一套测试环境。对于单元测试来说,测试环境通常是Mock的,并且不同用例由于被测代码依赖的差异,可能使用完全不同的Mock。几乎可以认为:无Mock,不单测。

做好单测,重点要做好Mock。然而,Mock并不是一件容易的事情。举一个例子,已知有两个类A和B,A是依赖B的。



如下图所示,对A进行Mock测试,包括4个步骤:

第1步,Mock创建(Line 3-4):创建Mock B。具体来说,是Mock对象B中被A所依赖的方法(B.doSomething)。创建Mock的前提是搞清楚A和B之间的契约:A是怎么调用B的,B又返回什么样的结果给A。

第2步,Mock注入(Line 6):实例化被测类A,同时注入第1步创建好的Mock B。这一步的目的是确保A调用的是Mock的B,而不是真实的B。

第3步,Mock使用(Line 8):对A进行测试,验证A的行为是否符合预期。A在运行过程中,会调用依赖的B。此时,由于第2步的注入动作,A调用的是Mock的B。

第4步,Mock校验(Line 10):验证A是否调用了Mock的B,并且是否以预期的入参调用了Mock的B。这一步的本质是验证A是否遵守了A和B之间的契约。



从这个例子可以看出,Mock测试依赖于两个重要的前提:

契约:契约是Mock的基础,没有契约就无法创建Mock。

代码可测性:可测性是Mock的关键,被测类的依赖必须是独立的、可控制的(依赖注入原则)。可测性不好,会导致无法注入Mock。

契约和代码可测性都属于代码设计层面的问题。如果这两个问题在代码设计阶段没有处理好,那么在代码实现和测试阶段,任何努力都是于事无补的。为什么单元测试推广难?一个重要原因就是存在历史遗留代码,它们在契约设计和可测试设计方面存在着巨大的技术债,导致针对这些代码编写单测用例时困难重重,除非进行代码重构。

在实践中,对于Mock测试,除了契约和可测性,还有一个问题需要考虑清楚:当测试某个类时,是否要将它的所有依赖类全部Mock?

答案显然是NO。那么,下一个问题来了,什么样的依赖需要Mock,什么样的依赖不需要Mock呢?这个问题没有标准答案,但是有些经验法则可以参考。

当A依赖B时,以下情形,不建议Mock B:

B是A的本地依赖:例如系统内置类(ArrayList等)、Utility类(StringUtils等)

B是A的简单依赖:B的逻辑非常简单

B是A的实体依赖:例如数据类,只有简单的getter、setter方法,没有复杂处理逻辑

B是A的独占依赖:B只被A依赖,不被其他类依赖

当A依赖B时,以下情形,建议Mock B:

B是A的外部依赖:例如外部HTTP服务,需要复杂的设置或返回结果不可控

B是A的复杂依赖:B的逻辑复杂,返回结果有很多情形

B是A的慢依赖:B的某些行为很慢,例如存在等待、超时

B是A的共享依赖:B不止被A依赖,还被C、D、E依赖

总之,做好单测的重点在于做好Mock测试,而Mock测试强依赖于代码设计,包括契约设计、可测性设计。并且,在进行Mock测试时,我们需要根据上下文,决策是否要对每一个具体的依赖类进行Mock。

五 与集成测试的分工问题

根据上述讨论,单元测试并不是一定要Mock的。如果我们同时测试了两个类A和B,甚至更多类,那么我们做的到底是单元测试还是集成测试呢?应该说,单元测试和集成测试没有绝对的分界。那么,在实践中,单元测试和集成测试到底应该如何分工合作呢?或者说,如果我们已经有了充分的集成测试,是否一定需要补充单测呢?

举一个在微服务架构下的常见例子。如下图所示,有一个CRUD应用,假设它的功能十分简单,就是提供某种资源(resource)的增、删、改、查功能。针对这个应用,有两种测试策略:

集成测试:将应用作为一个整体,测试应用对外提供的的API。此时,集成测试也叫接口测试、API测试。

单元测试:对应用的各个类,包括controller、service、repository、model等,分别(或者两三个组合在一起)进行测试。



从测试效率角度来看,由于API测试工具的成熟度,集成测试的编写和执行效率未必比单元测试低多少;从测试有效性角度来看,如果controller、service、repository、model等类都没有复杂的处理逻辑,只是承担简单的数据封装和代理职责,那么集成测试完全可以实现与单元测试相当的测试覆盖度。这种情况下,我们还是一定要做单测吗?

这就是测试金字塔或者单元测试容易遭受挑战的场景。应该说,我们反对“唯单测论”,反对教条主义式做单测,而是要回归单测本质,在真正需要单测的场景下做单测。那么,什么样的场景需要单测(甚至单测是不二选择)呢?那就是本文前面几节中提到的场景:

逻辑复杂:我们的被测对象不是只有简单的CRUD操作,而是有着更复杂的业务逻辑。

边界测试:被测业务或代码有很多分支、组合场景,我们想把这些场景测试得更全面。

Mock测试:被测代码有些外部依赖,我们想聚焦在被测代码上、避免测试受外部依赖干扰。

归根结底,单元测试的优势在于对代码逻辑的深度覆盖,而集成测试的优势在于对组件交互的广度覆盖。

在实践中,我们要有全局意识,统筹考虑单元测试和集成测试,在必要的时候,随时准备从单元测试切换到集成测试、或者从集成测试切换到单元测试。



正如这张图所示:

针对复杂的代码逻辑模块,选择单元测试。

针对主干业务流程,选择集成测试。

在做集成测试时,如果发现存在很多分支场景需要覆盖,那么考虑切换到单元测试。

在做单元测试时,如果发现我们的测试脚本与被测代码重复度高(意味着我们过度分割了被测对象),那么考虑扩大被测范围,切换到集成测试。

六 单测度量问题

做任何事情,总是离不开度量,单测也不例外。如何衡量单测做得好不好?够不够?大家都知道一些覆盖率度量指标。但是,这些指标之间是什么关系?在什么发展阶段选择什么样的度量指标?这个问题讨论得不够充分。

单元测试覆盖率,典型的度量指标有行覆盖率、分支覆盖率、路径覆盖率和mutation覆盖率。我对它们的理解如下:

行覆盖率:

表示已经覆盖的代码行数的比例,是最基础的指标。

一般来说,行覆盖率达到85%就已经是很高了。

分支覆盖率:

表示已经覆盖的代码分支的比例。

虽然分支覆盖率和行覆盖率是两个独立的指标,但是经验告诉我们,分支覆盖率通常比行覆盖率低。据我观察,当行覆盖率80~90%时,分支覆盖率通常只有50~60%。

可以认为,分支覆盖率是一种比行覆盖率更严格的指标。

路径覆盖率:

相比行覆盖率和分支覆盖率,还有一种更严格的指标,叫做路径覆盖率。

它表示已经覆盖的代码执行路径的比例。

由于组合爆炸,程序可能的执行路径是非常庞大、甚至无法穷举的。因此,路径覆盖率只是一个理论上的指标,在实际中几乎没有人使用。

mutation覆盖率:

行覆盖率和分支覆盖率有一个共同特点,即关注已经覆盖的部分是没有意义的,要关注没有覆盖的部分:为什么没有覆盖?有没有风险?需不需要覆盖?如何增加用例来覆盖?

mutation覆盖率关注的则是:对于已经覆盖的代码,是否实质覆盖了?

表面覆盖:某一行代码被执行到了。

实质覆盖:某一行代码被执行到了,并且当这一行代码中存在BUG时,测试用例会失败。

mutation覆盖率来源于mutation测试,即变异测试。如下图所示,它故意修改程序源代码,将if(a == b || b == 1)修改成if(a != b || b == 1),然后执行测试用例,观察是否有用例失败。修改源代码的操作叫做注入mutant。如果有用例失败,则说明这个mutant被kill掉,符合预期;否则,这个mutant存活,不符合预期。



mutation覆盖率表示被kill掉的mutant的比例,其数值越高,说明用例的BUG发现能力越强,测试的有效性越高。因此,通常认为mutation覆盖率是一种比行覆盖率、分支覆盖率更严格,并且切实可行的单测有效性度量指标。

在实践单测时,不同发展阶段我们关注的度量指标不同:

初级阶段:

在单元测试初级阶段,即研发团队开始引入和推广单元测试时,建议关注行覆盖率、分支覆盖率。

尤其是分支覆盖率,更能体现单元测试价值:一些通过集成测试很难touch到的代码分支,通过单元测试可以touch到。

高级阶段:

在单元测试高级阶段,即研发团队的单元测试逐渐成熟、行与分支覆盖率达到较高水平时,建议关注mutation覆盖率

mutation覆盖率可以度量测试用例的真实有效性,更好地驱动单测改进。

七 总结

本文探讨了单元测试中的5个关键策略问题:用例设计问题、边界测试问题、Mock测试问题、与集成测试的分工问题、度量问题,并给出了作者的解决之道。一家之言,仅供参考。

个人认为,能否解决好这5个问题,将是我们能否把单元测试真正做好并且最大化其价值的关键所在。

   
次浏览       
相关文章

中央计算的软件定义汽车架构设计
汽车电子控制系统中的软件开发过程
一文读懂汽车芯片-有线通信芯片
OTA在汽车上有哪些难点痛点?
相关文档

汽车设计-汽车的整体结构及动力系统
自动驾驶汽车软件计算框架
SysML在汽车领域的应用实践
电子电气架构-大陆汽车系统架构平台
相关课程

AutoSAR原理与实践
功能安全管理体系(基于ISO26262)
MBSE(基于模型的系统工程)
基于SOA的汽车电子架构设计与开发

最新活动计划
LLM大模型应用与项目构建 12-26[特惠]
QT应用开发 11-21[线上]
C++高级编程 11-27[北京]
业务建模&领域驱动设计 11-15[北京]
用户研究与用户建模 11-21[北京]
SysML和EA进行系统设计建模 11-28[北京]
 
 
最新文章
在EA中内嵌文档- Artifact
EA中模型视图
EA中的实体关系图
使用EA进行风险建模
EA中的项目词汇表
EA的模型导出或导入csv文件
自定义表格(Custom Table)在EA中的使用
Gap Analysis Matrix(差距分析矩阵)
更多...   
MBSE工具
MBSE平台
建模工具 EA
模型库-Model Center
需求管理-ReqManager
自动建模-Modeler
多级仿真-Sys Simulator
代码工程-Code Engineer
文档生成器-DocGenerator
更多...   
成功案例
广汽研究院 SysML+EA+软件分析设计
高合汽车研发部门 建模工具EA、WebEA、学习视频
国汽智联 建模工具EA、模型库、WebEA和iSpace
亿咖通 MBSE工程体系与工具链咨询
中航无人机 MBSE工具链
吉利汽车 购买EA工具
华科汽车零部件 购买EA工具
东风岚图汽车 购买EA工具 以及EA定制开发
更多...