5个关键问题让单元测试的价值最大化
阿里妹导读
一、背景
关于“什么是单元测试”、“为什么要做单元测试”、“怎么做单元测试”,网络上相关的技术文章汗牛充栋。尽管如此,在推广单元测试的过程,通过与研发同学的交流,我发现大家对单元测试的探讨还是存在薄弱的地方。这个薄弱的地方既不是抽象的单元测试理论,也不是具体的单元测试工具,而是理论与实践结合的单元测试策略。
就像测试策略一样,单元测试策略决定了我们能否把单元测试真正做好(而不是流于形式),并且让单元测试产生的价值最大化(而不是与集成测试做类似甚至重复的事情)。
本文讨论的单元测试策略不是空泛的,而是来自于单元测试实践中遇到的真实问题,即:
用例设计问题
边界测试问题
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 ";
}
用例5: 开始前3天,预期显示3d
边界来源:Line 4,if (days == 2) 解读:当倒计时时间超出24h且刚好为整数天时,不需要向上取整+1
用例6: 开始前1天1秒,预期显示24h 0m 1s
边界来源:Line 13,if (days == 1) 解读:当剩余天数为1时,倒计时小时数需要+24
从边界测试角度,我们还可以发现另外一个有趣的结论。我们推动研发开展单元测试,并不只是为了提升测试效率(相比黑盒的集成测试、系统测试,单元测试有更快的运行速度、更低的排错成本、更及时的质量反馈),更是为了提升测试有效性(相比黑盒测试,单元测试由于代码可见性,有能力去更全面地覆盖真实存在的、容易隐藏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个问题,将是我们能否把单元测试真正做好并且最大化其价值的关键所在。
阿里云开发者社区,千万开发者的选择
阿里云开发者社区,百万精品技术内容、千节免费系统课程、丰富的体验场景、活跃的社群活动、行业专家分享交流,欢迎点击【阅读原文】加入我们。
微信扫码关注该文公众号作者