0


【单元测试】如何让单元测试的价值最大化

如何让单元测试的价值最大化 

1.背景

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

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

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

  • 用例设计问题
  • 边界测试问题
  • Mock 测试问题
  • 与集成测试的分工问题
  • 度量问题

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

2.用例设计问题

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

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

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

和任何其他类型的测试一样,单测做得好不好,不在于我们是怎么测的(

how

),而在于我们测了什么(

what

)。然而,“测什么”,“不测什么”,这是测试设计阶段要解决的问题,单测也不例外。因此,在写单测之前,我们需要认真设计一下测试用例。那么,如何设计单测用例呢?这就要回归到测试的基础理论:黑盒测试白盒测试

在这里插入图片描述

  • 黑盒测试:将被测代码当作黑盒,基于程序对外提供的功能(包括它的输入、输出、以及输入输出作用关系)设计测试用例。典型方法包括边界值分析、等价类划分、决策树、状态机转换等。
  • 白盒测试:将被测代码当作白盒,基于程序内部的实现结构(包括条件、分支、循环等语句)设计测试用例。典型方法有语句覆盖、分支覆盖、条件覆盖、代码路径覆盖等。

显然,黑盒法和白盒法各有特点。对于单元测试来说,如下图所示,综合运用黑盒测试和白盒测试两种方法进行用例设计,是一种提升用例有效性的办法。
在这里插入图片描述
以 Apache 开源的 Commons Lang 库中的子串函数

StringUtils.substring(String str, int start)

为例,来说明图示方法的核心思路。该函数的源代码如下图所示,给定字符串

str

和子串起始位置

start

,这个函数返回对应的子串。

publicstaticStringsubstring(String str,int start){if(str ==null){returnnull;}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

范围内、范围外。根据决策表法,枚举入参组合作为测试用例。由于组合情况多,运用等价类划分法精简用例。

设计用例如下:

// 字符串为nullassertEquals(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 
    
   
     % 
    
   
  
    100\% 
   
  
100% 的覆盖度不是必须的)或对被测代码质量有信心(这种信心建立在知道自己测了什么、没测什么的基础上,是真正的信心,而不是盲目自信)为止。

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

在这里插入图片描述

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

3.边界测试问题

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

那么,什么地方 BUG 多呢?经验告诉我们,边界场景 BUG 多。这里的边界场景是相对于主干流程(即程序的

happy path

)而言的,它包含了程序的各种 分支

branch

)、角落

corner

)、边缘

edge

)、异常

exceptional

)、无效

invalid

)场景。那么,如何在边界场景找 BUG 呢?这就要用到边界测试(

boundary testing

)方法。

如何开展边界测试?如图所示,边界测试通常有三步:

  • 寻找边界
  • 分析边界值
  • 设计边界用例

边界测试的核心在于第 1 步,即 寻找边界

在这里插入图片描述
如何寻找边界呢?有两种方法。一种是黑盒法,从需求中寻找边界。另一种是白盒法,从代码中寻找边界

举个例子。假设我们有这样一个需求,实现一个倒计时,展示距离某大型活动开幕的时间,要求如下:

  • 超过 48 小时,展示向上取整天数
  • 48 小时及以内展示时分秒
  • 活动开幕后,倒计时消失

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

根据需求描述,我们可以推导出 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、secondsif(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 的各种边界场景)。

4.Mock 测试问题

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

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

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

在这里插入图片描述
如下图所示,对 A 进行 Mock 测试,包括 4 个步骤:

  • Mock 创建(Line 3-4):创建 Mock B。具体来说,是 Mock 对象 B 中被 A 所依赖的方法 B.doSomething。创建 Mock 的前提是搞清楚 A 和 B 之间的契约:A 是怎么调用 B 的,B 又返回什么样的结果给 A。
  • Mock 注入(Line 6):实例化被测类 A,同时注入第 1 步创建好的 Mock B。这一步的目的是确保 A 调用的是 Mock 的 B,而不是真实的 B。
  • Mock 使用(Line 8):对 A 进行测试,验证 A 的行为是否符合预期。A 在运行过程中,会调用依赖的 B。此时,由于第 2 步的注入动作,A 调用的是 Mock 的 B。
  • 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 的 实体依赖:例如数据类,只有简单的 gettersetter 方法,没有复杂处理逻辑。
  • 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。

5.与集成测试的分工问题

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

举一个在微服务架构下的常见例子。如下图所示,有一个 CRUD 应用,假设它的功能十分简单,就是提供某种资源(

resource

)的增、删、改、查功能。针对这个应用,有两种测试策略:

  • 集成测试:将应用作为一个整体,测试应用对外提供的的 API。此时,集成测试也叫接口测试、API 测试。
  • 单元测试:对应用的各个类,包括 controllerservicerepositorymodel 等,分别(或者两三个组合在一起)进行测试。

在这里插入图片描述
从测试效率角度来看,由于 API 测试工具的成熟度,集成测试的编写和执行效率未必比单元测试低多少;从测试有效性角度来看,如果

controller

service

repository

model

等类都没有复杂的处理逻辑,只是承担简单的数据封装和代理职责,那么集成测试完全可以实现与单元测试相当的测试覆盖度。这种情况下,我们还是一定要做单测吗?

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

  • 逻辑复杂:我们的被测对象不是只有简单的 CRUD 操作,而是有着更复杂的业务逻辑。
  • 边界测试:被测业务或代码有很多分支、组合场景,我们想把这些场景测试得更全面。
  • Mock 测试:被测代码有些外部依赖,我们想聚焦在被测代码上、避免测试受外部依赖干扰。

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

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

在这里插入图片描述
正如这张图所示:

  • 针对复杂的代码逻辑模块,选择单元测试。
  • 针对主干业务流程,选择集成测试。
  • 在做集成测试时,如果发现存在很多分支场景需要覆盖,那么考虑切换到单元测试。
  • 在做单元测试时,如果发现我们的测试脚本与被测代码重复度高(意味着我们过度分割了被测对象),那么考虑扩大被测范围,切换到集成测试。

6.单测度量问题

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

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

  • 行覆盖率- 表示已经覆盖的代码行数的比例,是最基础的指标。- 一般来说,行覆盖率达到 85 % 85% 85% 就已经是很高了。
  • 分支覆盖率- 表示已经覆盖的代码分支的比例。- 虽然分支覆盖率和行覆盖率是两个独立的指标,但是经验告诉我们,分支覆盖率通常比行覆盖率低。据我观察,当行覆盖率 80 80 80~ 90 % 90% 90% 时,分支覆盖率通常只有 50 50 50~ 60 % 60% 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 覆盖率可以度量测试用例的真实有效性,更好地驱动单测改进。

7.总结

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

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


本文转载自: https://blog.csdn.net/be_racle/article/details/139030426
版权归原作者 G皮T 所有, 如有侵权,请联系我们删除。

“【单元测试】如何让单元测试的价值最大化”的评论:

还没有评论